diff --git a/application/pom.xml b/application/pom.xml index dba7d27c1b..4cbd9c3b64 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -419,6 +419,10 @@ + + dev.langchain4j + langchain4j-ollama + diff --git a/application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json b/application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json index 81f9e6a14d..e614c9b54c 100644 --- a/application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json +++ b/application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json @@ -10,27 +10,9 @@ "externalId": null }, "metadata": { - "firstNodeIndex": 0, + "firstNodeIndex": 2, "nodes": [ { - "additionalInfo": { - "description": "Process incoming messages from devices with the alarm rules defined in the device profile. Dispatch all incoming messages with \"Success\" relation type.", - "layoutX": 187, - "layoutY": 468 - }, - "type": "org.thingsboard.rule.engine.profile.TbDeviceProfileNode", - "name": "Device Profile Node", - "configuration": { - "persistAlarmRulesState": false, - "fetchAlarmRulesStateOnStart": false - }, - "externalId": null - }, - { - "additionalInfo": { - "layoutX": 823, - "layoutY": 157 - }, "type": "org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode", "name": "Save Timeseries", "configurationVersion": 1, @@ -41,13 +23,12 @@ "type": "ON_EVERY_MESSAGE" } }, - "externalId": null + "additionalInfo": { + "layoutX": 823, + "layoutY": 157 + } }, { - "additionalInfo": { - "layoutX": 824, - "layoutY": 52 - }, "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", "name": "Save Client Attributes", "configurationVersion": 3, @@ -60,25 +41,23 @@ "sendAttributesUpdatedNotification": false, "updateAttributesOnlyOnValueChange": true }, - "externalId": null + "additionalInfo": { + "layoutX": 824, + "layoutY": 52 + } }, { - "additionalInfo": { - "layoutX": 347, - "layoutY": 149 - }, "type": "org.thingsboard.rule.engine.filter.TbMsgTypeSwitchNode", "name": "Message Type Switch", "configuration": { "version": 0 }, - "externalId": null + "additionalInfo": { + "layoutX": 347, + "layoutY": 149 + } }, { - "additionalInfo": { - "layoutX": 825, - "layoutY": 266 - }, "type": "org.thingsboard.rule.engine.action.TbLogNode", "name": "Log RPC from Device", "configuration": { @@ -86,13 +65,12 @@ "jsScript": "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);", "tbelScript": "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);" }, - "externalId": null + "additionalInfo": { + "layoutX": 825, + "layoutY": 266 + } }, { - "additionalInfo": { - "layoutX": 824, - "layoutY": 378 - }, "type": "org.thingsboard.rule.engine.action.TbLogNode", "name": "Log Other", "configuration": { @@ -100,97 +78,92 @@ "jsScript": "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);", "tbelScript": "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);" }, - "externalId": null - }, - { "additionalInfo": { "layoutX": 824, - "layoutY": 466 - }, + "layoutY": 378 + } + }, + { "type": "org.thingsboard.rule.engine.rpc.TbSendRPCRequestNode", "name": "RPC Call Request", "configuration": { "timeoutInSeconds": 60 }, - "externalId": null + "additionalInfo": { + "layoutX": 824, + "layoutY": 466 + } }, { - "additionalInfo": { - "layoutX": 1126, - "layoutY": 104 - }, "type": "org.thingsboard.rule.engine.edge.TbMsgPushToCloudNode", "name": "Push to cloud", "configuration": { "scope": "CLIENT_SCOPE" }, - "externalId": null + "additionalInfo": { + "layoutX": 1126, + "layoutY": 104 + } }, { - "additionalInfo": { - "layoutX": 826, - "layoutY": 601 - }, "type": "org.thingsboard.rule.engine.edge.TbMsgPushToCloudNode", "name": "Push to cloud", "configuration": { "scope": "SERVER_SCOPE" }, - "externalId": null + "additionalInfo": { + "layoutX": 826, + "layoutY": 601 + } } ], "connections": [ { "fromIndex": 0, - "toIndex": 3, + "toIndex": 6, "type": "Success" }, { "fromIndex": 1, - "toIndex": 7, + "toIndex": 6, "type": "Success" }, { "fromIndex": 2, - "toIndex": 7, - "type": "Success" - }, - { - "fromIndex": 3, - "toIndex": 1, + "toIndex": 0, "type": "Post telemetry" }, { - "fromIndex": 3, - "toIndex": 2, + "fromIndex": 2, + "toIndex": 1, "type": "Post attributes" }, { - "fromIndex": 3, - "toIndex": 4, + "fromIndex": 2, + "toIndex": 3, "type": "RPC Request from Device" }, { - "fromIndex": 3, - "toIndex": 5, + "fromIndex": 2, + "toIndex": 4, "type": "Other" }, { - "fromIndex": 3, - "toIndex": 6, + "fromIndex": 2, + "toIndex": 5, "type": "RPC Request to Device" }, { - "fromIndex": 3, - "toIndex": 8, + "fromIndex": 2, + "toIndex": 7, "type": "Attributes Deleted" }, { - "fromIndex": 3, - "toIndex": 8, + "fromIndex": 2, + "toIndex": 7, "type": "Attributes Updated" } ], "ruleChainConnections": null } -} +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/alarms_table.json b/application/src/main/data/json/system/widget_types/alarms_table.json index 4f86957f1c..446129972b 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 @@ -12,18 +12,12 @@ "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.typeParameters = function() {\n return {\n supportsUnitConversion: 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 'cellClick': {\n name: 'widget-action.cell-click',\n multiple: true\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", - "settingsSchema": "", - "dataKeySettingsSchema": "", "settingsDirective": "tb-alarms-table-widget-settings", "dataKeySettingsDirective": "tb-alarms-table-key-settings", "hasBasicMode": true, "basicModeDirective": "tb-alarms-table-basic-config", - "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"enableSelection\":true,\"enableSearch\":true,\"displayDetails\":true,\"allowAcknowledgment\":true,\"allowClear\":true,\"allowAssign\":true,\"displayActivity\":true,\"displayPagination\":true,\"defaultPageSize\":10,\"defaultSortOrder\":\"-createdTime\",\"enableSelectColumnDisplay\":true,\"enableStickyAction\":false,\"enableFilter\":true,\"entitiesTitle\":null,\"alarmsTitle\":\"Alarms\"},\"title\":\"Alarms table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"alarmSource\":{\"type\":\"function\",\"dataKeys\":[{\"name\":\"createdTime\",\"type\":\"alarm\",\"label\":\"Created time\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.021092237451093787},{\"name\":\"originator\",\"type\":\"alarm\",\"label\":\"Originator\",\"color\":\"#4caf50\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.2780007688856758},{\"name\":\"type\",\"type\":\"alarm\",\"label\":\"Type\",\"color\":\"#f44336\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.7323586880398418},{\"name\":\"severity\",\"type\":\"alarm\",\"label\":\"Severity\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":false,\"useCellContentFunction\":false},\"_hash\":0.09927019860088193},{\"name\":\"status\",\"type\":\"alarm\",\"label\":\"Status\",\"color\":\"#607d8b\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.6588418951443418},{\"name\":\"assignee\",\"type\":\"alarm\",\"label\":\"Assignee\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.5008441077416634}],\"entityAliasId\":null,\"name\":\"alarms\"},\"alarmSearchStatus\":\"ANY\",\"alarmsPollingInterval\":5,\"showTitleIcon\":false,\"titleIcon\":\"warning\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"widgetStyle\":{},\"displayTimewindow\":true,\"actions\":{},\"alarmStatusList\":[],\"alarmSeverityList\":[],\"alarmTypeList\":[],\"searchPropagatedAlarms\":false,\"configMode\":\"basic\",\"alarmFilterConfig\":null}" + "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"enableSelection\":true,\"enableSearch\":true,\"displayDetails\":true,\"allowAcknowledgment\":true,\"allowClear\":true,\"allowAssign\":true,\"displayActivity\":true,\"displayPagination\":true,\"defaultPageSize\":10,\"defaultSortOrder\":\"-createdTime\",\"enableSelectColumnDisplay\":true,\"enableStickyAction\":false,\"enableFilter\":true,\"entitiesTitle\":null,\"alarmsTitle\":\"Alarms\"},\"title\":\"Alarms table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"alarmSource\":{\"type\":\"function\",\"dataKeys\":[{\"name\":\"createdTime\",\"type\":\"alarm\",\"label\":\"Created time\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.021092237451093787},{\"name\":\"originatorDisplayName\",\"type\":\"alarm\",\"label\":\"Originator\",\"color\":\"#4caf50\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.2780007688856758},{\"name\":\"type\",\"type\":\"alarm\",\"label\":\"Type\",\"color\":\"#f44336\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.7323586880398418},{\"name\":\"severity\",\"type\":\"alarm\",\"label\":\"Severity\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":false,\"useCellContentFunction\":false},\"_hash\":0.09927019860088193},{\"name\":\"status\",\"type\":\"alarm\",\"label\":\"Status\",\"color\":\"#607d8b\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.6588418951443418},{\"name\":\"assignee\",\"type\":\"alarm\",\"label\":\"Assignee\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.5008441077416634}],\"entityAliasId\":null,\"name\":\"alarms\"},\"alarmSearchStatus\":\"ANY\",\"alarmsPollingInterval\":5,\"showTitleIcon\":false,\"titleIcon\":\"warning\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"widgetStyle\":{},\"displayTimewindow\":true,\"actions\":{},\"alarmStatusList\":[],\"alarmSeverityList\":[],\"alarmTypeList\":[],\"searchPropagatedAlarms\":false,\"configMode\":\"basic\",\"alarmFilterConfig\":null}" }, - "tags": [ - "alert", - "alerts" - ], "resources": [ { "link": "/api/images/system/alarms_table_system_widget_image.png", @@ -36,5 +30,10 @@ "data": "iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAIAAADGnbT+AAAABmJLR0QA/wD/AP+gvaeTAAAUUElEQVR42u2dh1sUVxeH/ctsUWMSSUETY/xSTDRKNNUYG4IgmhgVC7ZYA7FEERVUNAqoCIJdaeIConSwUAQEBO73zp5l3KAsEHcV3PN78vjcnXJ35s47554ZNr8zxBjz5MmTmpqakpKSOyrVCwiEAKmtrQ2ohkBVaWlpfX19e3u7UaleQCAESOAEVENAjA86KCpvCZyAagjhS2OVyrtxC6iGMDXqWKi8K6BSsFQKlkrBUilYCpZKwVIpWCoFS6V66WDxvisxMfGv3pSRkaEDquoHWJcuXZozZ87q1avX9qzly5ezTXV19aA47fT09JCQkObmZiXgVYJ15swZoPF8GfLz89nm9u3bg+K0+3JGqtcHrFWrVq34t3z0R8zBAhZ/zd2+ffuCBQsYGa+kwrt27Tp48CCNx48f+/T0BxZYe/bs4cy/+uqrKVOm7HLKz8H65ptvvvvuu3/++ee333575513Hjx48IIdXrx48fr163IPL1q0yL+mwiVLloSGhtox7Ny5c5LnMbi1tbXkRvv37//555+3bdsm2DHcZHgsYXlHR8drAxZn98YbbyQlJdHmvBiKW7du0T59+jQnGxYWVlxcfP/+fQaEf1nO6cfFxdE4f/48Z8dy2T4hIWHHjh1sf+XKFbbhI31+/PHH48eP52NMTMyhQ4fYjN7YpampyV/Amj17Ng3GheWVlZVDhw5lIUPz7rvv7t69m1XTpk1jLfn4hx9+yNPrc/ssKyvbu3evHf+6nVFFRQVr+W3aQGNrzZo1Y8eOXbduHT+ds0POm2++yelv3ryZ8+WYP/nkE1jp7OwMDAxkeW5u7ujRoxkHiAkICOAcV65cOWrUKLYHHQaWBKO8vHzu3LmzZs0qKiqKjY2dMGECu+/cuZOR9JeIdePGjZEjR9bV1b399tvciAIWlLBq06ZNP/744927d1kSHR3N4H7//fcQ9tw+2feXX37hxhW23M8IqhYvXsyXPnz4cKCBxfWW8DN8+HD4IG4tW7Zs6tSpnOyBAwc4cQacJGzevHkEpzFjxnBGUPjpp5+yQXx8PHsxgOxoz3oClvtUyFmzGbtD1d9//+0vYDGy3E+///47Nx9MuIPFHTZz5syCggKWcGdvduro0aM9dctrNthiAuUut89IqALHqqqqgUYVl5xZT+4Eh8MxbNiwq1evMjJffPHF5i5x/Fw+kKItNxXcEMPsDYhSgGWP57NgIeYEcgnw8uLbooEOFmJ0GFPYoi1gcTwcDHcY789aWlpgjjSfu5mbmwvgoWdhi1ucKYOjLSwsHLBUIX7dO2LEiMOHD8MWw8sgkCQRqMiNIKCxsZGMSrDjcYfpj2SA9okTJxgQ7j2GiA24i54LFrfiDz/8ICnp8ePH6ZyZ8TVP3hmIX3/91f7I5QcmxtQGa8aMGYw4M8K9e/dYyKpJkyZxw3GnkmF47pxHAdgKDg7maPkXiLl+AzZ/T01N5dQ4ZfIqbjDiNyQRXUY7Zb+OIQeXfEvS/KioKGIYeRVnx/8ww0d7PGnwkUZmZiYbMAK0SdhpSwrvRy9ICTOkpTKC9lT47GuI1tbWvncIWxztgI1V3URUfvaB0fOLGPDq9VmEHiCVBk8GgOXdFLNPYHGXcxlIBo/1LCYjtuEn9N4dU7oleScVlY/uOdYLwhoeHj4oqPK1iBqEt/Xr17+C91g8lDE9zfEoyV28/j6TNzTkp+43GY/KXvkWXj0rVaihocEXP2/Rn82ofCIFS6VgqRQslYKlYKkULJWCpVKwFCyVgqVSsFQKlkqlYKkULJVfg6VSeV0asVQ6FaoULJWCpWCpFCyVgqVSsFQqBUulYKkULJVKwVIpWCoFS6V6iWBhXYKfzgGPwoopKytLB1TVD7CuXbsmZqketHDhQnxBxGVVpfLTAgIq/wILg9DNbqI+j79fn+IY6z93Fe00t3c/f+OSOFOVrGA9R5g+Xr58GaM9HEdp4Pj7kodD7IcHEFipH5hTQ01Dl7FqbY71MW3i8zeuPGXuZypYPYpYJQbuorNnz+KliZO2WKURxniSoJgAPm84whHkWEsWyKqbN2/imrxlyxbW5uXlue754mIwjYyMFB9AHGDxQGcJH3EfxdOb3cUWli/Fk5JkkQHBxBCyjdOBcsOGDcZpa8iO+H9iBMeR4NkcERHByPgcrHPjTf5a18ebq6yPLrA6TflRkx1hHJtMW521oCzBVPxjNZ40W4EtK8zcjcWszjkKe0z5EZP7m3lcpWC5qKLmB3BwIcHFOMsFUIcMx1vcpCmvQAEPfFrfe+89Hl3xQsZRGM9q9mIJ2OGrieMt2F24cGHy5Mk4vQIoDTZ49OjRZ599BqMcM/1gFIhVLra5rMLzc+vWrfv27TNO41cqFcg0jX86a1nCV+NhyVEFBQWJ664PwboVZc4EmM4npr3VnHnb+ihgFe8y5z40VUnm0kyTFWItyV5s8lY4n7bmmotBpiLRnP/M3FxjLWEb9s1fbVruKViWKKBAiCJ44Br6wQcfCFhcURpgRLCRzT766CMYYoltD8x8CgR4Wc+fP/+yU3hWM80BFkzINlhOQiFm1BgwY+XNEkoQ5OTk0HguWOKvD5FvvfWW9Ekw87p7Z3ewyo+b9P+Z6rMWQ5lfWmFJwCIsNeRbISo7zKR98hSslofm1DBrFao+Z1LGms4OC6zCbToVPgXr22+/ZcaRXP6PP/5wByslJWXp0qWyGSGHijruYGGQz8wFQwQV+2kA710bLCIcnYMLJv0Eqr6Dhcc69ZLsPtndt2CVxlvZ+vX55trP1tRWEusCq/SQSR1v7uw12eEm3Q2s5gorD3tcaS2py7La7S1OsHYoWE/BooCHzQHTX69gkX1LKKJ+GIdEaSdmUrHJZ3fcp22wmPjwRhfn9OnTp0uahae+vOblAIRjSqp0A4vemHCZOqUT3xZKscA6bFrum+TRJmWMaa0zJftdYF2bY3KXWw0Hk+PHT8HqbLf2KolzrtpoMr5wTYUKljtY5EmUVqNneJK3D57BokFlLGY9smxZRQkGZjqq7jCrkjzZYIEUFFI+hLVQKFX8oIclJP5wA3Z8Lzt2A8s43bxJ1Jhk2ZEnBp+DZWE0z2SFOt8pdIFVddqijczpwlST9IaVpNs51v0LVlp25l2TGmjqchUsl7j83aoBEBXscmc0xI4bg3z7kKj/Ick7UyH7Un3OfXd2sSv9deuckENv9kJo41HR7l8qX5BUPbsj2zAn+qic4lO1N5qONmej1frP1WjsWttsxTCQetJgPSReX2DyVrlWEbdaalyPhNbJNLl2H1Bg8egENCdPnjzds6i6wTZMHK/w3Yl7juV34pVE0kgrwR9E77H4CyAPWXN6EyWTXm3JPwrdUrrCT8FiymtwDJzD6evPZphomnuTUan6C5ZKpWCpFCyVgqVSKVgqBUulYKlUCpZKwVIpWCqVgqVSsFSvJVgqlRYQUOlUqFKwVCoFS6VgqRQslUrBUilYKgVLpVKwVAqWSsHqSZ7/b1WsDXQ0Vf0DC0+OZcuW9fq/2GO76HOTDNXrBBb2m4PCFEQ1yMBSn/dXLOaBnqYCp8VS94376KPBvgkJxjemGwMLLDzWsEGbOHEi/mk06PAlX0EMI33r0NdfNTSY4GAzfLgZOdKEhJgukzCX2trMiBEmO9tqJyYacdrZssX0cdyYXoYONTU1/hKxutlxG6evn9g92rLt1/i3gdH/1x3b3vTvC8DHbk5unIu9O+ZH9nI8SHNzcz0cm4fv9YnCwsz06da1578pU0xUVPdYRY2Zzk6rMWYMPmZWg6co9wOrq3NtYMcz+3z9GSzc9PAF/emnn7DOlnoCWEUGBwdDAEagbEaDVeLUjfEacQ7LWiy4Mbe1e8Ni9MsvvxSHSKwi6YGI6HA4sIfE65H+cZcEFPxOca39+uuvMVd+rrmt7FhUVITXMo6SHBUGpz70bwIRAtXZs66P+M7HxroYmjbNDBuG6aBFBsMCc3xkeUaG2bTJzJ5tbeZwmIkTzdixZvx4I1URtm61Po4bZ4U09vVnsGgLEPQMHwIWlrU0KDnG1CnutAEBARweYIGIc4pogzb4wAUezmQbaMNBDrCEQvTnn3+KUxxssaXpzTWZsgPGaRUGXpQRkMOLlYvtC/EVXPhnK2UAELcN9RBssNwjlg3WjBnGWfrAYBjOsBDhVqywYCJiMYFevOjXYM2aNQuSfnRqDGPXHztuDNyBEgt42R34qCTg7vPOx9DQUEpREPz6bsddVVU1atQo6fPzzz9fsWKFr8DiknPhncfTHay0NKvhASwyB6Jdt+IGSUncQ2bqVCtpS0nxa7AoVZeWltbQpb6DFR4ezksQCghQxsLenbhlg8XpwJOY3lIWrxtY1FPZu3ev6aGAwPvvv09mJn36cCokHwoIMIcPuz5y2CEhfQULMeXJNIpFLwVguMpMlw6Ha5Wfg8XLM4IWxv9UFuHSmj7YcZM8sTHXnpQfY2NctfnIORLDOAUbLJgghrEK695x48bRm3EWwoiPj6dUzokTJ5hVeS1HqZVn7bg50+joaPxOWSLzsq+0Z48Fwf79BspJj3j06wmswEDDTcWDoQ0W0yWpWHKyCQqysn4GDbA4d/zradCVv4FFqRJK39gfk5OTmbCYccDLOIs0iUs2KVSiDLT1iL2F0CIFBKKioghX2fIQbkxBQQGe73ZMolCA3Tl27SynUAAwXSTnwII/O5snA3YhthG0WMtX2EWabIb4LtYyh8bExJDP+fbBkPkrNNRwCwlMxrKu56xcIY2J2Fl8ygpOvJjAdJ6GpH1gJ0EOOuU12MmT1jYs3L7d6o1oze6+ebZ9rV6Q+rUd92B88858ATT8ufDXnkWQIB+SWPKqRNRhptOLOmjAkqIPf3kUWZEU+FOpjP4eS6VgqRQslYKlYKkULJWCpVKwFCyVgqVSsFQKlg6ESsFSKVgqBUul8j5YKpX6vKt0KlQpWCqVgqVSsFQKlkqlYKkULJWCpVIpWCoFS6VgPVeY+5zuTWqZrOofWHl5eXP6oPnz5+McpGOqMv1ym8GAykNliqysLLXjVv0XsF6CjVF5efmLO4sUFhbmuNkrYpVTWlra307w4sJI0n0JnrZimMst9HKy0nvN9y5XX5X23YaSwroiaWfdyyl7VN7f3tLLM2qaa/wULGz1QsQN8QWEy97GjRvtjxEREdjz9bcTXN3O2nbFXVkmRpLG6cBGbmCc3rjuBnFeFzDNSw1ubGukvTM3OvLKGhodpmNRemjug7z+9naoIOFO/V0Fy7iHHGwagUMM1uPi4qTBBcYJUlJA27/PA1jYG8uObE/UMU7fZQDCUguGODvcuWGlxmmdSNiT2InjMruzHNNKAYvlrMUfEOPusLAw7P/YUfxRibj4PXnr2nR0doSkL7710NFhOhelhcLTo9ZH5Y8qbNpy7ueevHPqWvV1tuTjhYqLBXWFSXeSWzpaUkrOENVSSs9kVGayO2szKy9UNFY0tjWxqryxnM2uVl/rdK6it9OlZ9PK0h21BXkPbvoFWKdOnZoxYwZXMTIyUhxHMYMEC6431rTiJrpkyZLMzEx3sPCGLO8Sho4CFo6jmIvSwLUWo0dZQrcAivlxUFAQ3w5kM2fOZNXRo0fXrLEiBFvyUAJ5s2fPFrDWrl175MgRfFA5XywqAZEDYHvZGG9SL973W7K2Jt89TaQhXO3Iic6qyb5UeXnV5UhWJRafWHkpEiCWXljOvyxZeWnV4vPhO3NiHrU2zk1duPbqhuPFJ1gCMaxdduH3S5VXmF5ZteXG1mO3ExekBYMRI7n26voN1zcfLToGvn/n7/MLsIgKUioC87cJEybU1tZihMzl5+uIFvjesgoDd/fCE4A1efLkkC5hbusBLFmClSiOusZZb0JMv22wAgMDpQiKPRUKWDTAV+ITE6L4y1OOAJS9CNaJ4pPRubuIMfFFR86WpR4qiD9UGH/AcZBVJEwEnuL6O2ywMydawDpTmirTJfQU1lo5GaDE3orrBlZtSy1LgIm4VdpQxpKGVqvORawjzl/Aoq6OOGYjggqOyNiswxOTI5eTbyTFxuq4LzmWB7CIfLbH5OjRo93BEs48gwWOhE9GQEoceFH5D/IJSFuzthFamNqIW+uubrxcZbm3p5WnsQrg1l/bRAYmYDHf2WDdrr0trOzL398NrLoW663QHze2MpPmP7gVnO4a8wP+AxbTkLgdi996Y6OVW1DXBFNuikokJCTQOGw7oXsEi4AnsWe7U30Ei1lS3L8xOH0WLGZq2QurZqqqUFvPu2A1PWmal7qQ7Iq0iTkrPGMp8xdwsIoJ8VxZmhOdAzty/vzPYD14/JCkTR4zo3N2vbZgcevbsxi98ZBP8RKuJTDZl42Km8yDxlmhc/jw4ZVS9ao3sCiVQ+kbEjW84PsOFi7wkyZNWrlyJfGyG1h8ETO11ErhIWDYsGG+8PZdfWUtc5a0Y/J2w5Zk3MduHyclIj2CGLb5z2DROOg4TCq25sq68MyI1xMsnq1uuUmKeMnbV2ZAezPe79vzo4QTd0FbjZspPtjJ3wO44zlISCVREwJ4oJOiYvRmM+Fw1m6Q4gOyhN7I8zgYMZpnud0hT6MSBUmtBDuvi4jy8HGttOtb62uan7LLE2JlUyXPdDSsA2uuJm23Dsx0EoFa2q1iyqRTklHJlk/a21klT5FkaXRI41Gb9bBZ3VRD5DtSeOzlgcUzEdBgn3+jZ1G4hm388+/ZZHs8uqbZBv+DTRtvbCaNI1ELy4hwB9fnYJWVlS1YsKDXvxUyy/hnyXFedvi28ImPRRjjBRgv9CXgvTywjLMyUWlv6lZsUuXP0t9jqRQslYKlUrAULJWCpVKwVAqWgqVSsFQKlsqvweJvq/KbXZXKKwInoBrCX+/r6+t1OFTeEr/+AKohbW1t/KUPtjRuqV48VgESONEYwmd+jQlihC81kVa9iEAIkCRC/R9f3OEsEgi6eAAAAABJRU5ErkJggg==", "public": true } + ], + "scada": false, + "tags": [ + "alert", + "alerts" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/attributes_card.json b/application/src/main/data/json/system/widget_types/attributes_card.json index 6246811769..2beedd2a76 100644 --- a/application/src/main/data/json/system/widget_types/attributes_card.json +++ b/application/src/main/data/json/system/widget_types/attributes_card.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "", "templateCss": "#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n margin: 5px;\n padding: 8px;\n}\n\n.tbDatasource-title {\n font-size: 1.200rem;\n font-weight: 500;\n padding-bottom: 10px;\n}\n\n.tbDatasource-table {\n width: 100%;\n box-shadow: 0 0 10px #ccc;\n border-collapse: collapse;\n white-space: nowrap;\n font-size: 1.000rem;\n color: #757575;\n}\n\n.tbDatasource-table td {\n position: relative;\n border-top: 1px solid rgba(0, 0, 0, 0.12);\n border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n padding: 0px 18px;\n box-sizing: border-box;\n}", - "controllerScript": "self.onInit = function() {\n \n self.ctx.datasourceTitleCells = [];\n self.ctx.valueCells = [];\n self.ctx.labelCells = [];\n \n for (var i=0; i < self.ctx.datasources.length; i++) {\n var tbDatasource = self.ctx.datasources[i];\n\n var datasourceId = 'tbDatasource' + i;\n self.ctx.$container.append(\n \"
\"\n );\n\n var datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n\n datasourceContainer.append(\n \"
\" +\n tbDatasource.name + \"
\"\n );\n \n var datasourceTitleCell = $('.tbDatasource-title', datasourceContainer);\n self.ctx.datasourceTitleCells.push(datasourceTitleCell);\n \n var tableId = 'table' + i;\n datasourceContainer.append(\n \"
\"\n );\n var table = $('#' + tableId, self.ctx.$container);\n\n for (var a = 0; a < tbDatasource.dataKeys.length; a++) {\n var dataKey = tbDatasource.dataKeys[a];\n var labelCellId = 'labelCell' + a;\n var cellId = 'cell' + a;\n table.append(\"\" + dataKey.label +\n \"\");\n var labelCell = $('#' + labelCellId, table);\n self.ctx.labelCells.push(labelCell);\n var valueCell = $('#' + cellId, table);\n self.ctx.valueCells.push(valueCell);\n }\n } \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.valueCells.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData && cellData.data && cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n var textValue;\n //toDo -> + IsNumber\n \n if (isNumber(value)) {\n var decimals = self.ctx.decimals;\n var units = self.ctx.units;\n if (cellData.dataKey.decimals || cellData.dataKey.decimals === 0) {\n decimals = cellData.dataKey.decimals;\n }\n if (cellData.dataKey.units) {\n units = cellData.dataKey.units;\n }\n txtValue = self.ctx.utils.formatValue(value, decimals, units, true);\n } else {\n txtValue = value;\n }\n self.ctx.valueCells[i].html(txtValue);\n }\n }\n \n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n}\n\nself.onResize = function() {\n var datasourceTitleFontSize = self.ctx.height/8;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n datasourceTitleFontSize = self.ctx.width/12;\n }\n datasourceTitleFontSize = Math.min(datasourceTitleFontSize, 20);\n for (var i = 0; i < self.ctx.datasourceTitleCells.length; i++) {\n self.ctx.datasourceTitleCells[i].css('font-size', datasourceTitleFontSize+'px');\n }\n var valueFontSize = self.ctx.height/9;\n var labelFontSize = self.ctx.height/9;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n valueFontSize = self.ctx.width/15;\n labelFontSize = self.ctx.width/15;\n }\n valueFontSize = Math.min(valueFontSize, 18);\n labelFontSize = Math.min(labelFontSize, 18);\n\n for (i = 0; i < self.ctx.valueCells; i++) {\n self.ctx.valueCells[i].css('font-size', valueFontSize+'px');\n self.ctx.valueCells[i].css('height', valueFontSize*2.5+'px');\n self.ctx.valueCells[i].css('padding', '0px ' + valueFontSize + 'px');\n self.ctx.labelCells[i].css('font-size', labelFontSize+'px');\n self.ctx.labelCells[i].css('height', labelFontSize*2.5+'px');\n self.ctx.labelCells[i].css('padding', '0px ' + labelFontSize + 'px');\n } \n}\n\nself.onDestroy = function() {\n}\n", + "controllerScript": "self.onInit = function() {\n\n self.ctx.datasourceTitleCells = [];\n self.ctx.valueCells = [];\n self.ctx.labelCells = [];\n\n for (var i = 0; i < self.ctx.datasources\n .length; i++) {\n var tbDatasource = self.ctx.datasources[i];\n\n var datasourceId = 'tbDatasource' + i;\n self.ctx.$container.append(\n \"
\"\n );\n\n var datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n\n datasourceContainer.append(\n \"
\" +\n tbDatasource.name + \"
\"\n );\n\n var datasourceTitleCell = $(\n '.tbDatasource-title',\n datasourceContainer);\n self.ctx.datasourceTitleCells.push(\n datasourceTitleCell);\n\n var tableId = 'table' + i;\n datasourceContainer.append(\n \"
\"\n );\n var table = $('#' + tableId, self.ctx\n .$container);\n\n for (var a = 0; a < tbDatasource.dataKeys\n .length; a++) {\n var dataKey = tbDatasource.dataKeys[a];\n var labelCellId = 'labelCell' + a;\n var cellId = 'cell' + a;\n table.append(\"\" + dataKey.label +\n \"\");\n var labelCell = $('#' + labelCellId, table);\n self.ctx.labelCells.push(labelCell);\n var valueCell = $('#' + cellId, table);\n self.ctx.valueCells.push(valueCell);\n }\n }\n\n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.valueCells\n .length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData && cellData.data && cellData.data\n .length > 0) {\n var tvPair = cellData.data[cellData.data\n .length -\n 1];\n var value = tvPair[1];\n var textValue;\n //toDo -> + IsNumber\n\n if (isNumber(value)) {\n var decimals = self.ctx.decimals;\n var units = self.ctx.units;\n if (cellData.dataKey.decimals ||\n cellData.dataKey.decimals === 0) {\n decimals = cellData.dataKey\n .decimals;\n }\n if (cellData.dataKey.units) {\n units = cellData.dataKey.units;\n }\n txtValue = self.ctx.utils.formatValue(\n value, decimals, units, true);\n } else {\n txtValue = self.ctx.utilsService\n .customTranslation(value);\n }\n self.ctx.valueCells[i].html(txtValue);\n }\n }\n\n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n}\n\nself.onResize = function() {\n var datasourceTitleFontSize = self.ctx.height / 8;\n if (self.ctx.width / self.ctx.height <= 1.5) {\n datasourceTitleFontSize = self.ctx.width / 12;\n }\n datasourceTitleFontSize = Math.min(\n datasourceTitleFontSize, 20);\n for (var i = 0; i < self.ctx.datasourceTitleCells\n .length; i++) {\n self.ctx.datasourceTitleCells[i].css(\n 'font-size', datasourceTitleFontSize +\n 'px');\n }\n var valueFontSize = self.ctx.height / 9;\n var labelFontSize = self.ctx.height / 9;\n if (self.ctx.width / self.ctx.height <= 1.5) {\n valueFontSize = self.ctx.width / 15;\n labelFontSize = self.ctx.width / 15;\n }\n valueFontSize = Math.min(valueFontSize, 18);\n labelFontSize = Math.min(labelFontSize, 18);\n\n for (i = 0; i < self.ctx.valueCells; i++) {\n self.ctx.valueCells[i].css('font-size',\n valueFontSize + 'px');\n self.ctx.valueCells[i].css('height',\n valueFontSize * 2.5 + 'px');\n self.ctx.valueCells[i].css('padding', '0px ' +\n valueFontSize + 'px');\n self.ctx.labelCells[i].css('font-size',\n labelFontSize + 'px');\n self.ctx.labelCells[i].css('height',\n labelFontSize * 2.5 + 'px');\n self.ctx.labelCells[i].css('padding', '0px ' +\n labelFontSize + 'px');\n }\n}\n\nself.onDestroy = function() {}", "settingsSchema": "{}", "dataKeySettingsSchema": "{}\n", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Attributes card\",\"decimals\":null}" @@ -29,4 +29,4 @@ "public": true } ] -} \ No newline at end of file +} 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 01034a1d1d..f58bd3f0ce 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,19 +11,13 @@ "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 supportsUnitConversion: 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: true\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", - "settingsSchema": "", - "dataKeySettingsSchema": "", + "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 supportsUnitConversion: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'displayName', 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: true\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", "settingsDirective": "tb-entities-table-widget-settings", "dataKeySettingsDirective": "tb-entities-table-key-settings", "hasBasicMode": true, "basicModeDirective": "tb-entities-table-basic-config", "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"enableSearch\":true,\"enableSelectColumnDisplay\":true,\"enableStickyHeader\":true,\"enableStickyAction\":true,\"reserveSpaceForHiddenAction\":\"true\",\"displayEntityName\":false,\"displayEntityLabel\":false,\"displayEntityType\":false,\"displayPagination\":true,\"defaultPageSize\":10,\"defaultSortOrder\":\"name\",\"useRowStyleFunction\":false,\"entitiesTitle\":\"Entities\"},\"title\":\"Entities table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"datasources\":[{\"type\":\"function\",\"name\":\"Simulated\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Entity name\",\"color\":\"#2196f3\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.472295003170325,\"funcBody\":\"return 'Simulated';\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Entity type\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.782057645776538,\"funcBody\":\"return 'Device';\",\"decimals\":null,\"aggregationType\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.904797781901171,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\",\"decimals\":0},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Cos\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.1961430898042078,\"funcBody\":\"return Math.round(1000*Math.cos(time/5000));\",\"decimals\":0},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.7678057538205878,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\",\"decimals\":2}],\"alarmFilterConfig\":{\"statusList\":[\"ACTIVE\"]}}],\"displayTimewindow\":false,\"configMode\":\"basic\",\"actions\":{},\"showTitleIcon\":false,\"titleIcon\":\"list\",\"iconColor\":null}" }, - "tags": [ - "administration", - "management" - ], "resources": [ { "link": "/api/images/system/entities_table_system_widget_image.png", @@ -36,5 +30,10 @@ "data": "iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAAnhSURBVHja7d3rU5NXHsBx/7KAa8WuBRahaEAIiQRFQSAKXlrUpagYAmgBJbXKin3qKlaKYAOmtFlQLpWkgCgSRC6GiwZFQAi3J/nuC9Da2RGSTNtZmXNeJWfmOfl9Juc2c855zjoWnU8HPvD01LnAukXHpMwHnuRJx+I65yRrIE061z2V1wJEfrpugDWRBgREQAREQAREQATkQ4A4X/sBkXW34IHO7d9PfqXT6XS6Tr9DztXpsv/zP7mJZf5AFEEjNCn8nEZ2WE4HWSxjfkPUxyyXAxv/IEjoAZoUsnxxx24b5ScOaaqMquwFHqSoL3u8KK1mM0h6xrT9WYXalF7mC2MODnsPKYXdxfycFFdOR2qJKkfmB83hqDK601RFc2Sc1e61HlJVeQep/NjSpJDrEvuMERRHtF9UlHZs+nk6+N8PQhu9hLSuf10V4dakdx1RY4zvyU/2HlI43LKp9rmysUXR3xhQ3RpoHQysatlc5gr5qlt9lvAs++6/35M2LHoFuVuxxayQaSw5qJCLM+hRTBBXfjdQklTnvITIYebDxWiu0a14vf2AVBDodUVVb/xEUeKhtzQvoLnxI9hSW7UNEstaAxe5GU14DcZ99CvGvIO4E6MV8g8RTZVvIeprdwIlSWrxEkLRgaBHaK7Ro5iMPiRJ0oIPVSvtCI6NVY8Cmxs/gvCam9GQWGZbL1P1KeE1GHXeQ+hdr5CLdoycVcy/hYxvKnd++8RbSJciCjSZz/O2U6Ad6pR8aSMPA7qa1/fUBzQuQfoC7jwKKZsOufYs0eAbRGmDUqU8unvLKaXj8in6lVNk/IBVG3z4hReh1O8AUH4NmpStqk5mcsKjzV5DMq7DiVNydmhmgskWC4n1XI1IzqigPSHkn69JrOdKNkPK8b9qQBzd1A+aax/8yN4WkMeagMjjYq4lIAIiIAIiIAIiIAIiIALyZ0MG10QSVUtABERABERABERABERABOQDh3TchuYGuN3x62kZ6DeMAy3lUG8wGDrp0J9aYT+Ay2Aoaf1dTleVL7G4/vVFpZupkpNLy5XdBoOhDsau1PgOeZQIGamwy14R0ADkBzhgIiINci5YLCNPtj9sC3//hoDJTdb6/SXv5ox1+QLJPTeYcZMMqUfdCXAj22LpoU9Z3u87RA6enY/WuOZC5IqkDHB9qnbAcWMapPcCLT+Cru39kGBY3OrgSXZWtysXGuvuV/K6+OBVGXtW9uNVY0noozZ3PsRDTQHAVzUAGc1+tZH0tl9yC5vaM6goiRml6ssdDpqz7qeBWvtprgw83z63IoQTNRNRXfaomaQe0jvNeg5dGfvixliUvStqdrVYTJ/XJXe6wxZo+AzgpGrrgVeEHIk/NO075JtvCu+0nJEkKr68+jU7+2McM/Hj99OgdWp+fzXM7bGxMiTvpinFYtllqzo/Fesx62c3u3nZ9f1+i2XHqrttftpbstvOqayquFMA9mFPUcHMhl6KLvoO6Tiocc1pDndSUTChfJhClOPcLmN25A2A6jPIn1WyCmRva0WSJEmDU+rai5j1M1sArqRKkrTqppTQ19j2IdfVSpeXc7qT5kLgXqbvkIXwdMiIWKSigOPRdUQ5Hlut1+O7ZiLnOF1O7iVWgdSq5K74RboWyYxxYNYT20/9d22JMg9W3coR7qTxADC0fRgZVCNUnyDhAZLRj3Ek5TpcT4WKAjpDF4hyAJ1pUKlO3udqCtRqtRfeDwnURmc+hzJV0iEXdxLBrOd+3L7UF3yt2pM5t1osDap0zWM82lQb6NqxqXXxw9jVKUkTf+SAKLu870fl37VOF4A848Vznt82y7kBz1IxM/70WmKKIiACIiACIiACIiACIiAC8kFAxDq7qFoCIiACIiACIiACIiACIiDvJL8OlM+/+bDgPyRmnPH4jp4UAKKUSqW8WH/4KsB0dN1qZU2FaGOyfneAtD7fh1AcOp1ubyyt0QlZCwDlqp0Zs0D1Nj8gW164Ekx0awEWIgFqC7MlgNzIVdftJ4PBpJHB9e4C7tK5xJk5rzDVRs+2QU5WAcPRC2RVg1PziT+QZ/svswwZ3WVrdwOlEmDLzPMKwl4bl7QJ52fD5zh/zazHFpuhdXJ+Z7w3Lwpwq52z/4DG48DkY8irgc+b/YKkR8hvID0RZ5KOLkNmtU4vIfk37Qmye8dQTgOqF2a9RzmA6XzHHo8c+3z1aOqPg3KIsuVl3L74WX7KnfULcvLYuTcQQI4cXoKcvYmXkOOmGxE6Xdhd2xf9+zHrJ8MAvo3U6UKtq0ezuwcsW1N1ZwAY1zxmYsdr/yAvZmLuLEPsfUsll0rMRWu1IVvrvIAsRA7U5kxNTS26VedrMevlYDdz45X5U1NTq/dgHakAslx4C2Am8R7cjNHGB+7yB8KTiJHuGLvd/qptz0jztvnlNoI3/8jH9ub0LxmPbB06PYUxxIVZzxHJmVPpjPz1af7qq7MHmwDaL+ycp2l8Mc1ot/cDfv0jJdPQcHPUYDAY2rEczR0EGpd2HZlX3YLhMhiKmoD+3EwLOK5DVy2ui0eroVefeXfVWFzFHsBTZJoFaXDcYDAYSoHFIjGyC4iACIiACIiACIiACIiACMj/OUSss4uqJSACIiACIiACIiACIiAfOMQ6Bcz+eOsl4G7zqaxFi6W+782Xh8McAOCY65mXr5L3NFf2APS9Ws6YvMOoxWKx2PyAvNpwBRa0pd8px7msCvQJMhkkXdr55uxoXScbAQib7jZ59/zJE7diG/hp18Y3h8ezg+mTJCnzlB+Q8jNxMHIRjluwzHzkGyQY+mO4P4irjvuDbISe8s6w6aF2fh6+UedBNlU+6Xjv4/NaN7U5tIwlL0OaMoMBSO31A6IZzegEGIwbAnyEbB52nMunwIQzigITG7Fvu1UQOG3WE3uwZreJ7OxbcXkrFlFYASxDZuKfBQN07fOjjTxKoU4PnFFmy75D/nb0s6iGdyEFlRA6bdYT68BUMB0iY1oRYkuWf4Pk354NBjjS7AckP9l4ZpML8GT+6DskGCY/WXgHktkMYdNmPbGj1BqG1awM6Y1z8hbSEWm5HWQBh9rjO2QurMVq/dzUWwLnKvyCzATNF1fgWIaUXEUOfguZC3Xx/QqQUc0gv0HuS1JZ0BXIq/aj+zUfA2zJ84n5xrhx3yEbDLlxl7inNO5dhowqiw8HvYVwYU9x1AoQVbrRaHQvQQzdwGwwjEfO+wEZeA64bR75119cADafILLV2vES6G9+1cngGDaYaHZ2yi8H6JzjxQDuh7arKxyyt1mtVqsHumdgYA6Q2+DlE/yA/Mkp52KVsvdPKvsvhcw3Vo2IuZaACIiACIiACIiAeANZMxcEr40rmyec6xbWxiXa8rq1ca25zH8BTrZIsxZexqkAAAAASUVORK5CYII=", "public": true } + ], + "scada": false, + "tags": [ + "administration", + "management" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/markdown_html_card.json b/application/src/main/data/json/system/widget_types/markdown_html_card.json index 64a847952a..b66e3ee88e 100644 --- a/application/src/main/data/json/system/widget_types/markdown_html_card.json +++ b/application/src/main/data/json/system/widget_types/markdown_html_card.json @@ -11,16 +11,10 @@ "resources": [], "templateHtml": "\n", "templateCss": "#container tb-markdown-widget {\n height: 100%;\n display: block;\n}\n\n#container tb-markdown-widget .tb-markdown-view {\n height: 100%;\n overflow: auto;\n}\n", - "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.markdownWidget.onDataUpdated();\n}\n\nself.actionSources = function() {\n return {\n 'elementClick': {\n name: 'widget-action.element-click',\n multiple: true\n }\n };\n}\n\nself.typeParameters = function() {\n return {\n dataKeysOptional: true,\n datasourcesOptional: true,\n hasDataPageLink: true\n };\n}\n\nself.onDestroy = function() {\n}\n\n", - "settingsSchema": "", - "dataKeySettingsSchema": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.markdownWidget.onDataUpdated();\n}\n\nself.actionSources = function() {\n return {\n 'elementClick': {\n name: 'widget-action.element-click',\n multiple: true\n }\n };\n}\n\nself.typeParameters = function() {\n return {\n dataKeysOptional: true,\n datasourcesOptional: true,\n hasDataPageLink: true,\n hideDataSettings: true\n };\n}\n\nself.onDestroy = function() {\n}\n\n", "settingsDirective": "tb-markdown-widget-settings", - "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"markdownTextPattern\":\"### Markdown/HTML card\\n - **Current entity**: ${entityName}.\\n - **Current value**: ${Random}.\",\"markdownTextFunction\":\"return '# Some title\\\\n - Entity name: ' + data[0]['entityName'];\",\"useMarkdownTextFunction\":false},\"title\":\"Markdown/HTML Card\",\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"showLegend\":false}" + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"const baseTemp = 20;\\nconst dailySwing = 10;\\nconst hourlyVariation = Math.sin((time % 24) * Math.PI / 12) * dailySwing;\\nconst randomness = (Math.random() - 0.5) * 2;\\nconst smoothingFactor = 0.8;\\nreturn (prevValue * smoothingFactor) + ((baseTemp + hourlyVariation + randomness) * (1 - smoothingFactor));\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"alarmFilterConfig\":{\"statusList\":[\"ACTIVE\"]}}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"useMarkdownTextFunction\":false,\"markdownTextPattern\":\"### Markdown/HTML card\\n - **Current entity**: ${entityName}.\\n - **Current value**: ${Temperature}.\",\"markdownTextFunction\":\"return '# Some title\\\\n - Entity name: ' + data[0]['entityName'];\",\"applyDefaultMarkdownStyle\":true,\"markdownCss\":\"\"},\"title\":\"Markdown/HTML Card\",\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"showLegend\":false,\"useDashboardTimewindow\":true,\"displayTimewindow\":true}" }, - "tags": [ - "web", - "markup" - ], "resources": [ { "link": "/api/images/system/markdown_html_card_system_widget_image.png", @@ -33,5 +27,10 @@ "data": "iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAABdFBMVEX////u7u7g4ODf398nJydISEiCs/Tx8fE/Pz+amppgYGC6urr7+/upqan6+vrW1tbKysr19fX9/f3j4+M5OTn39/fs7OxSUlJxcXHFxcVCQkI8PDyMjIxFRUV+fn40NDTa2trp6el3d3dVVVXAwMCKiorQ0NClpaX6/P8vLy+SkpJbW1srKyvl5eWhoaHn5+dMTEytra2Hh4dlZWVPT0+3t7eenp6Pj49CjO7j7v3MzMx7e3uBgYE2NjabwvbR0dHIyMjV5vvY2NgyMjJra2tUl/A0hO3a6fzA2fqEhIQ4hu1XV1f3+v7Dw8OXl5eUlJT0+P6y0Pi0tLTc3NzV1dWysrKwsLCnyfeIt/U+ie6vr690dHRJSUkqfezy9/641Pnz8/NkoPJIj++rq6vg7fyszfh8r/Tt7e28vLySvfZZmvDR4/uNuvV2q/Pp8v1ppPJMku9oaGifxfctf+zT09NnZ2dfnvHL3/vG3fpup/Lt9P4vgO3lU88CAAAQR0lEQVR42uyby2/aQBDGJ7jINti8DOb9dAA1lEASHhIQJWp7QSiJ1JYcaA6VckivkdL/v4aPLbs1bkqLlZTmOwzKesa7P8WKP80QIq1s+P5xGeWQzWGYez+L/jFpsqFR2dz750GI5DIZe7sAohnk2wkQ8r2ArBQ6o6XCUXpEMS7TS5DmLQcyjaVNIS6UmBFdC8eQ/LSUX3psu6K0NjP2absg5538CiSRp6QpxIVKr3zGq7d/CJJQE2sz5fhWQcz7+gokeJxX3wX5iIqLRu+wkaaj1HBAdH6c+tyyQUKXTaoNC3GJSoVRifQy0Z0u51KFMEFYCedyBtEy8+0oVZXJN6pEyBi/zuV823y0HlYgtUrl0qrxERXX6sm9ek1vS8HTL3RyUIskJH/ivUrNbPBrUTKVYFcxP5+3moOUpNSOsiFUYcVMdyK0zGxmu2eKZOaDEcWQg8V0WvYIhIb7el+I0GG98rneIznYzZfoJE1EktI4IqoX5g/MPA71/mxU+dCX/KtHCCtEgQjLPFxEPdDtBo7waHkEcnU6ydxc8REVB7pp6ld7mWovG16CZK0DIrU6P9g8VtXZQaBxNZ2DJMOowgpAlpnHi9hQVbXmKYisyFY4ykdUHM+IZtXIhMhiIP7EtxxF2lH7YOfJUKgTKSnqpVLiQbBC9D5Iy8wzJfwpLkU6i9tqrxNbBUkpbzIXS5DwSUyJCREqTImmhUSgHSiWGAiFrBnlFOudQceZTJW0otkqajwIVoiC8c7+MvM68L5o0FXWurHXh5nk/hO9EOVyiESFFivRKP1K0btlZixEzdO5+8Z9ZPnp3+x/pmYmf/PheVmUZ6EdBQkW+h93ASQcOCp0dgHEVvmNuRsg9cBu/EaCmfBOgKSVwU781bq7+bobf37DrzKZzHQHQHbnhfgC8vRyAdlqLwuKkSBPQczZqC+tQD49IG7WyxJrhY4WL09ByuPbyoSBaL7DnqEhbtbLEmuFjpYoLx+tj0UGIrUtqy0hbtjLEmqxz7KjNaikrgjC3bwC0U8uGQiVG4EW4sa9LL4WQkcrEb/4eE4Q7uYVyEHg3mQgh7p+iLhxL4uvhdAIit1UDWLC3bx6tO7a+wBh+vNeFiSAkKz6RwThbt6A2L+MVubcCbJ5L8shdLRiIdKyPvyMu3kDEslPlJHwHvmLXpYodLTKmUb7RMPPuJtHj1brobX43GIvS+xoaS1ZuNvTv9mfnf4HkNbt7YDM5dwiJrgsp9xz3N2apyCRDz9A9i19QGeFbER0SpLfZernmuMUcrwEOcsGViAp9vcfTok7gHPq55LzVCDliboWBE4JUz/ptFJ4u5z6wV9hDuiWg30Gw1x/JDvdWvmQqLcnV1Nj3zZBqr2va0HglDD1k7KRo6yGqR/8FeaAbjlLb3A1HI9Up1sLJ+feQFLObrPmFpsPlrkeBBFTv/kjkffhsYG/whzQLYeZnPpld+xwawsQ5Ff07YEcZxqd00tXEEz98O7GIeGvMAd05ogg1/Xu2OnWSh2Wn1O3ByI9POgTSQCBR0LE1A+HxNQP/gpzQLccBkLUHTvdmlz8MgfJx7RkeksgkPhoMY+EiKkfDompH/wV5oBuOTzIGrfWVyw7/40/M/TohQgQeCRENvWDuKkf5oAuOY+6tYSp2VBRmTwDuYhbqnPq58lkUPK/eK1HQeCyNtHg9raFp4fmmkZ/x00hE1H0YLHNHJoIct7r9VoAgcvaCES39oloL9ldWE4lxjsuRKeQiYgcVLn0wXD1cZDxZwbC3h0bKbU/Dz20Lo55x4XoFDIRkYMqOLc/Bxle20EAgTtCtwo+Cu4I69x3rhhIqY1nohEh3nEhwlOx7lYwyDIRkYMqODd+X1RJ8dGwRuik4QxrQe5TelkEgTtCtwo+CqYC6/x3rpYgl2jAmVmNeMeFCE/FulsTi2UiIgdVcG78vqiSil+D2RY6aTjD+tGbfp8sCyBwR+hWwUcBBOvCd64AMpwSETpevONChKdi3a1WE5mIyGFV2J3fd16FlUIdnTScwQkCtfcFELgjvKnho+COsC585wogYx21F0So4kHgqfjuFjIRkYMq7M7vi6r5SlVFJw1nWAticn0teCe4I9wQPgruCOvCd64AclTBsUNEvONChKdi3a16HZmIyGFV2J3fF1X2SqIdQScNZ1gHUspwfS14J7gj3BA+Cu4I68J3rgAiZw2ycT7bgXdciPBUrLsVaCCTReSgCrvz+6LKeNdQcoROGs7wWF8L3klwR/BRcEdYF75zBRC6tWSi+6BgsGREeCp0t9ANQybiKpPtLuyLKjTSUIszbG5R4KPcV1QrfrH41Cl6Gv09T4VMxF/7txevxSRMCWMOj4SrLg6q/IVc5TGIGTx0gvglZ+cK7ghXXR3ULOluO70FCXfe99xA4H9Ej+QOgqv9CrnKU5BJffVoofuEKaEwB4TPgUfCVazIl6mKAUfEHJQGSo/lBKkV+/0BA0H3CVNCfg4InwN3xM8QE+1cLS3DEeGqrQOVvJcTZHZzVI+XfoCkiU0J+fEZfA4++RniRYOITQlxxdYUH95LBJme2AZYF0DU6s8g8DlwR/wMcZoiYlNCXLWlV8l7OUHCSuuu81UAwZQQvgj+Bz4H7oifIfriBrEpIa7aGl6T5xJBoFy+k9oTQDAlhC+C/4HPgTvCVbaSTbbZlBBXqalEyVO5/48VfnKdEkbvBI8kXo21ovBR7Gp0sr1fiCcWBe7o8at6j9z1TECenV5Anpt2CeQ7u3b0mjYQwHH8l/kTE03aNUZtrVFrO0Ps1TnbKjilYvIioxaq60v7IPRBXwvr/79LRre6MdhgGemW70vCwR33gQsEkn8kSfknSiBxK4HErQQStxJI3EogcSuBxK3/DTLR8njqvPcby28hSNEG+K6IIVbNCfddq13gefe8xFOP7q+vzj6C5mzh+ybzKCGCJci6ZPNPQPRZ/acQ9UOkkBEngG5ZAWQ7/OicN1LOpYTImz2YudnrEDK+ck6AG8m73AOObnD/eqI5O5AVjRRM4xV0Y5AxLoCTdnsQQF71nKKxDSjObIyCMbKNQoSQW68LrFmVkDbv0mwjx31OJWRlW69wS8v1JKTKsueNcWrDVPdNlE/hqp7KLGRL0UWTQ2zTCI5WfSRUT0KanlAtaphbns1K0RaurUQJeW/p8NOOhOxWTbNjS8hpannPXMlb4IINM2O7GHMKxS6bPb79RC4OpNctK/CZgez4EH2W4fAkgOyKBQwJybopOBKSHR2gK/JRHy1/wLUitJ6EoNmu2J6EXAP3LPMcaHMQPiNvKGe1mJrwulXqXJ2zDvcxGJlDdiV2Og1OdtOQkHDDc7ZM9wFYUNtiyTCmzEUNyeLOb4viTEKmwn8GEWIXmLEeQobMfFGpw1KrX5qqeA5p8h0ndzN1GkJqjyFki40QsqJVkv0FiCbSPlps7ogGcPoV0uvzBjfMyTE3NKDKAzRULi5YbmxA9JFaRsXmOoSc2SbqbGH/EPhEDbVjADqihxQF1wFky+2Me8J9glzqh7V8xkuvg7Fi7WzPCLb+kappqrzegMDnFE2KZQjR2D3KSsgb9uVVk9fKQLNXOLOOCpFC8GDpAQQfPd5lefIEwdz9oB9Z7GRdYM8mfQVQOASGXG1CDI5hqscIIYVbIaoSsvQpHiSk0HXpXQGGYD4yyGZ6ET9krvAlZYlfbVkIZxYzhWuef/tLeJlB2Et7aXSs/sxSNzb/MiHLSid9m8fXXixkswSSQF5KCSRuJZC4lUDiVgL53N699jQNxXEc/6HHTWVDGXL1Ak6Ezg0F5dISLRTsWmvFFZWuKqut28LuczO7wJv3dJTBiGgMooh8k2U77U6yT5om/ydLT1udEO/u3zOXbs1hv14yg936iR9HNt6N7xRNMnEVu/FpHFFdgxsr/BZIHyErAJ6T4K9DhsePgGQOQfQ8DpczD0Gy0nEhq4+BrsgqhYz51/sxMHfB76WQt3O9uO5/40CWJ4cDWP6KT3P0bamve+BzdwC0lWvAxOR4AE6P3s94XUjAZKBqWlGmEEEXkCvUGskcUN9GTAYkvQE466Rel5Jgc2YqwZgKzx0TErzhw8ORcBCTkScjsz3D5NnqAJn5unoL8+TeZQp5Ebq6OoQ7YWyQCdyc95PpMLkF2q3HmJy+QneCgm6v3Rnpda/IZgy1Wl5kwbOFfDQnW6JWF8EoFcgWtDKfzaNSQE6JFwwerBgvZRleiUvHhHSvbmHw9b0gNrawSN4Ok/foJQs3PgR8o4/paXpR1unx5e5Qz1Bo4QIZ85MLGBp0Ic8n0fWgBZkAPl5vQxosgwaFlIA0Dz5NFYmGWEO8wigSElGVQpo8nBcrQ6VyVsBxIcHnK2RqOoi+4PRlcn2YvKGQCLmIKbLl3CMbpAtYveKNLI9emX1/M+An/XhxyYUsjoQnP4HWvxAeDI23IXoTEFhHgHim9dY0PWZ5W0wmq5Zl7UgUUq47ypagWvwNkPXrkQ9XcTuIl0/uruxBwrNhXxeZcyDjZAo9kYd4efVSb+hJEB0QBCaC90BbCPZjcB+ilQ5DUh5bzVdsFHeSHMcxFCI2fjMkcJusO5DLQwMLZHkXMjMWWsN0eMpP/N5nT7vWIhfxgNzHF/KuEzL7ClMRH4ChBxgb3WpDOIVDpQ2hvxrFaBM5xQOUU2B0B5IvQBXbELt+bAjWRvsdyEaIfCGTLgTzkYmlm+RqyI/hy+TZDLBIFjFDBjohr27P3ngI2tLNkcHZ+TYEFcW22hAhWgLEFMDKgFQuGy1bosba+5C4oh0D0lnPJ3QW8KJVlw9H5vV1ftctwahmFnupsY5zKpwYNYGahr22mdM1omQ8rRQxG7U8P8xSsmUlvb/mTxfELaFVJPwkSU/FzofG/wsS22TwneJ5nKbOKCQhcykZgt4AwKU0FaqcTDkQoQ5niDXB5QRdaEOSKXo00Sim5NaGhiRB3gZMZ6Mute7gnMABgl7HCdcJkaKlDGuVMoYG2eYLIrgd0UMhdUPA5jaKVWhKM6+YLkQrV5oWBKWUsXlIiidtZGDngCinimneqKOh5JuGDr3MizwOdbIQhYHGMqgU0JAQiHLczjZim3Ujhz1IFtBEFyInoVYTghKDVkMhAxT2IEUNyHhQ00FfapRDMcqgs5OFGIAsAqkSEvGsWJW4KCjEKKANsQAu6kK2PVlxsyiwgJyFKAPxPQhMK8umYUhAUxd2LMuqcujsT0EKeQaGC0mx5kGIZLiQEs+gugepmQcgOZsDn6YLB5JUONrfuiLZFOo7wi6EyRkcjDpkCrFjiDddSNmEvMm5kEyJUbMZ1FKQqpwpMqpVQNqDhK0ztgZVx6H+FERWWIs1XQh4kUkpdsmBiHaZcyGmwpaUhguJWYYtZug+u0Zv9prBNrMoimy5rEOgI+6fvNk7YxJo1Tm0ahYOHGdiBz8nAiW9vS/BOFvoQpTd1cnWCfH8NIs96kyJjq/RdOexmpEtG+5nE0f0d2YtyfzR+JrAoYSKpuKHnQ+N55BT3jnktHUOOW2dIUifD2cgXx/uenEG8t5FT5/3n78mzkO0z8hjzX34BpPgTEZLPbVzAAAAAElFTkSuQmCC", "public": true } + ], + "scada": false, + "tags": [ + "web", + "markup" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/photo_camera_input.json b/application/src/main/data/json/system/widget_types/photo_camera_input.json index 69327395cc..fc676380ab 100644 --- a/application/src/main/data/json/system/widget_types/photo_camera_input.json +++ b/application/src/main/data/json/system/widget_types/photo_camera_input.json @@ -15,7 +15,7 @@ "settingsSchema": "", "dataKeySettingsSchema": "{}\n", "settingsDirective": "tb-photo-camera-input-widget-settings", - "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Photo camera input\",\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"widgetTitle\":\"\",\"saveToGallery\":true,\"usePublicGalleryLink\":false,\"imageFormat\":\"image/png\",\"imageQuality\":0.92,\"maxWidth\":640,\"maxHeight\":480},\"title\":\"Photo camera input\",\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"showLegend\":false,\"actions\":{}}" }, "resources": [ { diff --git a/application/src/main/data/json/system/widget_types/timeseries_table.json b/application/src/main/data/json/system/widget_types/timeseries_table.json index 8343eb918c..15779eb902 100644 --- a/application/src/main/data/json/system/widget_types/timeseries_table.json +++ b/application/src/main/data/json/system/widget_types/timeseries_table.json @@ -17,7 +17,7 @@ "latestDataKeySettingsDirective": "tb-timeseries-table-latest-key-settings", "hasBasicMode": true, "basicModeDirective": "tb-timeseries-table-basic-config", - "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix('blue', 'red', percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: '20px',\\n color: '#ffffff',\\n background: color.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor('blue');\\n backgroundColor.setAlpha(value/100);\\n var color = 'blue';\\n if (value > 50) {\\n color = 'white';\\n }\\n \\n return {\\n paddingLeft: '20px',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}],\"latestDataKeys\":null}],\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showTimestamp\":true,\"displayPagination\":true,\"defaultPageSize\":10},\"title\":\"Timeseries table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{},\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"displayTimewindow\":true,\"configMode\":\"basic\"}" + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix('blue', 'red', percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: '20px',\\n color: '#ffffff',\\n background: color.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor('blue');\\n backgroundColor.setAlpha(value/100);\\n var color = 'blue';\\n if (value > 50) {\\n color = 'white';\\n }\\n \\n return {\\n paddingLeft: '20px',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}],\"latestDataKeys\":null}],\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"enableSearch\":true,\"enableSelectColumnDisplay\":true,\"enableStickyHeader\":true,\"enableStickyAction\":true,\"showCellActionsMenu\":true,\"reserveSpaceForHiddenAction\":\"true\",\"showTimestamp\":true,\"dateFormat\":{\"format\":\"yyyy-MM-dd HH:mm:ss\"},\"displayPagination\":true,\"useEntityLabel\":false,\"defaultPageSize\":10,\"pageStepCount\":3,\"pageStepIncrement\":10,\"hideEmptyLines\":false,\"disableStickyHeader\":false,\"useRowStyleFunction\":false,\"rowStyleFunction\":\"\",\"tabSortKey\":\"timestamp\"},\"title\":\"Timeseries table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{},\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"displayTimewindow\":true,\"configMode\":\"basic\"}" }, "resources": [ { @@ -32,4 +32,4 @@ "public": true } ] -} \ No newline at end of file +} diff --git a/application/src/main/data/json/tenant/device_profile/rule_chain_template.json b/application/src/main/data/json/tenant/device_profile/rule_chain_template.json index 305dc04961..8773a2d6aa 100644 --- a/application/src/main/data/json/tenant/device_profile/rule_chain_template.json +++ b/application/src/main/data/json/tenant/device_profile/rule_chain_template.json @@ -10,12 +10,12 @@ "configuration": null }, "metadata": { - "firstNodeIndex": 6, + "firstNodeIndex": 2, "nodes": [ { "additionalInfo": { - "layoutX": 822, - "layoutY": 294 + "layoutX": 824, + "layoutY": 156 }, "type": "org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode", "name": "Save Timeseries", @@ -30,8 +30,8 @@ }, { "additionalInfo": { - "layoutX": 824, - "layoutY": 221 + "layoutX": 825, + "layoutY": 52 }, "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", "name": "Save Client Attributes", @@ -48,8 +48,8 @@ }, { "additionalInfo": { - "layoutX": 494, - "layoutY": 309 + "layoutX": 347, + "layoutY": 149 }, "type": "org.thingsboard.rule.engine.filter.TbMsgTypeSwitchNode", "name": "Message Type Switch", @@ -59,8 +59,8 @@ }, { "additionalInfo": { - "layoutX": 824, - "layoutY": 383 + "layoutX": 825, + "layoutY": 266 }, "type": "org.thingsboard.rule.engine.action.TbLogNode", "name": "Log RPC from Device", @@ -72,8 +72,8 @@ }, { "additionalInfo": { - "layoutX": 823, - "layoutY": 444 + "layoutX": 825, + "layoutY": 379 }, "type": "org.thingsboard.rule.engine.action.TbLogNode", "name": "Log Other", @@ -85,27 +85,14 @@ }, { "additionalInfo": { - "layoutX": 822, - "layoutY": 507 + "layoutX": 825, + "layoutY": 468 }, "type": "org.thingsboard.rule.engine.rpc.TbSendRPCRequestNode", "name": "RPC Call Request", "configuration": { "timeoutInSeconds": 60 } - }, - { - "additionalInfo": { - "description": "", - "layoutX": 209, - "layoutY": 307 - }, - "type": "org.thingsboard.rule.engine.profile.TbDeviceProfileNode", - "name": "Device Profile Node", - "configuration": { - "persistAlarmRulesState": false, - "fetchAlarmRulesStateOnStart": false - } } ], "connections": [ @@ -133,11 +120,6 @@ "fromIndex": 2, "toIndex": 5, "type": "RPC Request to Device" - }, - { - "fromIndex": 6, - "toIndex": 2, - "type": "Success" } ], "ruleChainConnections": null diff --git a/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json b/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json index a988c9d5eb..c48dab1964 100644 --- a/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json +++ b/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json @@ -9,7 +9,7 @@ "configuration": null }, "metadata": { - "firstNodeIndex": 6, + "firstNodeIndex": 2, "nodes": [ { "additionalInfo": { @@ -92,27 +92,9 @@ "configuration": { "timeoutInSeconds": 60 } - }, - { - "additionalInfo": { - "description": "Process incoming messages from devices with the alarm rules defined in the device profile. Dispatch all incoming messages with \"Success\" relation type.", - "layoutX": 204, - "layoutY": 240 - }, - "type": "org.thingsboard.rule.engine.profile.TbDeviceProfileNode", - "name": "Device Profile Node", - "configuration": { - "persistAlarmRulesState": false, - "fetchAlarmRulesStateOnStart": false - } } ], "connections": [ - { - "fromIndex": 6, - "toIndex": 2, - "type": "Success" - }, { "fromIndex": 2, "toIndex": 4, diff --git a/application/src/main/data/resources/dashboards/gateways_dashboard.json b/application/src/main/data/resources/dashboards/gateways_dashboard.json index 381c9f6de0..078f913570 100644 --- a/application/src/main/data/resources/dashboards/gateways_dashboard.json +++ b/application/src/main/data/resources/dashboards/gateways_dashboard.json @@ -650,7 +650,7 @@ "settings": { "useMarkdownTextFunction": true, "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);\nif (data[0].Version) {\n createDataBlock(data[0].Version, \"Gateway Version\", '');\n}\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}
`;", + "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 ${ctx.sanitizer.sanitize(1, value)}\n `;\n}\ncreateDataBlock(data[0].Status, \"Status\", data[0].Status === \"Active\" ? 'divider-green' : 'divider-red');\ncreateDataBlock(data[0].Name, \"Gateway Name\", '', ctx.isMobile);\nif (data[0].Version) {\n createDataBlock(data[0].Version, \"Gateway Version\", '');\n}\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}" }, diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 320d3e5bdd..17512ed64c 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -27,13 +27,25 @@ SET profile_data = jsonb_set( CASE WHEN (profile_data -> 'configuration') ? 'minAllowedScheduledUpdateIntervalInSecForCF' THEN NULL - ELSE to_jsonb(3600) + ELSE to_jsonb(60) END, 'maxRelationLevelPerCfArgument', CASE WHEN (profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument' THEN NULL ELSE to_jsonb(10) + END, + 'maxRelatedEntitiesToReturnPerCfArgument', + CASE + WHEN (profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument' + THEN NULL + ELSE to_jsonb(100) + END, + 'minAllowedDeduplicationIntervalInSecForCF', + CASE + WHEN (profile_data -> 'configuration') ? 'minAllowedDeduplicationIntervalInSecForCF' + THEN NULL + ELSE to_jsonb(60) END ) ), @@ -43,6 +55,24 @@ WHERE NOT ( (profile_data -> 'configuration') ? 'minAllowedScheduledUpdateIntervalInSecForCF' AND (profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument' + AND + (profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument' + AND + (profile_data -> 'configuration') ? 'minAllowedDeduplicationIntervalInSecForCF' ); -- UPDATE TENANT PROFILE CONFIGURATION END + +-- CALCULATED FIELD UNIQUE CONSTRAINT UPDATE START + +ALTER TABLE calculated_field DROP CONSTRAINT IF EXISTS calculated_field_unq_key; +ALTER TABLE calculated_field ADD CONSTRAINT calculated_field_unq_key UNIQUE (entity_id, type, name); + +-- CALCULATED FIELD UNIQUE CONSTRAINT UPDATE END + +-- REMOVAL OF CALCULATED FIELD LINKS PERSISTENCE START + +DROP TABLE IF EXISTS calculated_field_link; +ANALYZE calculated_field; + +-- REMOVAL OF CALCULATED FIELD LINKS PERSISTENCE END diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index ea46ce86eb..35cf9cb467 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -97,6 +97,7 @@ import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.rule.RuleNodeStateService; @@ -116,6 +117,7 @@ import org.thingsboard.server.service.apiusage.TbApiUsageStateService; import org.thingsboard.server.service.cf.CalculatedFieldProcessingService; import org.thingsboard.server.service.cf.CalculatedFieldQueueService; import org.thingsboard.server.service.cf.CalculatedFieldStateService; +import org.thingsboard.server.service.cf.OwnerService; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.component.ComponentDiscoveryService; import org.thingsboard.server.service.edge.rpc.EdgeRpcService; @@ -511,6 +513,10 @@ public class ActorSystemContext { @Getter private ResourceService resourceService; + @Autowired + @Getter + private TbResourceDataCache resourceDataCache; + @Lazy @Autowired(required = false) @Getter @@ -566,6 +572,10 @@ public class ActorSystemContext { @Getter private JobManager jobManager; + @Autowired + @Getter + private OwnerService ownerService; + @Value("${actors.session.max_concurrent_sessions_per_device:1}") @Getter private int maxConcurrentSessionsPerDevice; @@ -654,6 +664,10 @@ public class ActorSystemContext { @Getter private long cfCalculationResultTimeout; + @Value("${actors.alarms.reevaluation_interval:120}") + @Getter + private long alarmRulesReevaluationInterval; + @Autowired @Getter private MqttClientSettings mqttClientSettings; @@ -846,8 +860,9 @@ public class ActorSystemContext { if (errorMessage != null) { eventBuilder.error(errorMessage); } - - ListenableFuture future = eventService.saveAsync(eventBuilder.build()); + CalculatedFieldDebugEvent event = eventBuilder.build(); + log.debug("Persisting calculated field debug event: {}", event); + ListenableFuture future = eventService.saveAsync(event); Futures.addCallback(future, CALCULATED_FIELD_DEBUG_EVENT_ERROR_CALLBACK, MoreExecutors.directExecutor()); } catch (IllegalArgumentException ex) { log.warn("Failed to persist calculated field debug message", ex); @@ -857,7 +872,7 @@ public class ActorSystemContext { private boolean checkLimits(TenantId tenantId) { if (debugModeRateLimitsConfig.isCalculatedFieldDebugPerTenantLimitsEnabled() && - !rateLimitService.checkRateLimit(LimitedApi.CALCULATED_FIELD_DEBUG_EVENTS, (Object) tenantId, debugModeRateLimitsConfig.getCalculatedFieldDebugPerTenantLimitsConfiguration())) { + !rateLimitService.checkRateLimit(LimitedApi.CALCULATED_FIELD_DEBUG_EVENTS, (Object) tenantId, debugModeRateLimitsConfig.getCalculatedFieldDebugPerTenantLimitsConfiguration())) { log.trace("[{}] Calculated field debug event limits exceeded!", tenantId); return false; } @@ -881,12 +896,13 @@ public class ActorSystemContext { return getScheduler().scheduleWithFixedDelay(() -> ctx.tell(msg), delayInMs, periodInMs, TimeUnit.MILLISECONDS); } - public void scheduleMsgWithDelay(TbActorRef ctx, TbActorMsg msg, long delayInMs) { + public ScheduledFuture scheduleMsgWithDelay(TbActorRef ctx, TbActorMsg msg, long delayInMs) { log.debug("Scheduling msg {} with delay {} ms", msg, delayInMs); if (delayInMs > 0) { - getScheduler().schedule(() -> ctx.tell(msg), delayInMs, TimeUnit.MILLISECONDS); + return getScheduler().schedule(() -> ctx.tell(msg), delayInMs, TimeUnit.MILLISECONDS); } else { ctx.tell(msg); + return null; } } diff --git a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java index 27bdec2422..20cacda26a 100644 --- a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java @@ -43,7 +43,6 @@ import org.thingsboard.server.common.msg.queue.QueueToRuleEngineMsg; import org.thingsboard.server.common.msg.queue.RuleEngineException; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.dao.tenant.TenantService; -import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; import java.util.HashSet; import java.util.Optional; @@ -89,16 +88,20 @@ public class AppActor extends ContextAwareActor { break; case PARTITION_CHANGE_MSG: case CF_PARTITIONS_CHANGE_MSG: + case CF_STATE_PARTITION_RESTORE_MSG: ctx.broadcastToChildren(msg, true); break; case COMPONENT_LIFE_CYCLE_MSG: onComponentLifecycleMsg((ComponentLifecycleMsg) msg); break; + case CF_ENTITY_ACTION_EVENT_MSG: + forwardToTenantActor((TenantAwareMsg) msg, true); + break; case QUEUE_TO_RULE_ENGINE_MSG: onQueueToRuleEngineMsg((QueueToRuleEngineMsg) msg); break; case TRANSPORT_TO_DEVICE_ACTOR_MSG: - onToDeviceActorMsg((TenantAwareMsg) msg, false); + forwardToTenantActor((TenantAwareMsg) msg, false); break; case DEVICE_ATTRIBUTES_UPDATE_TO_DEVICE_ACTOR_MSG: case DEVICE_CREDENTIALS_UPDATE_TO_DEVICE_ACTOR_MSG: @@ -108,7 +111,7 @@ public class AppActor extends ContextAwareActor { case DEVICE_RPC_RESPONSE_TO_DEVICE_ACTOR_MSG: case SERVER_RPC_RESPONSE_TO_DEVICE_ACTOR_MSG: case REMOVE_RPC_TO_DEVICE_ACTOR_MSG: - onToDeviceActorMsg((TenantAwareMsg) msg, true); + forwardToTenantActor((TenantAwareMsg) msg, true); break; case SESSION_TIMEOUT_MSG: ctx.broadcastToChildrenByType(msg, EntityType.TENANT); @@ -117,11 +120,11 @@ public class AppActor extends ContextAwareActor { case CF_STATE_RESTORE_MSG: //TODO: use priority from the message body. For example, messages about CF lifecycle are important and Device lifecycle are not. // same for the Linked telemetry. - onToCalculatedFieldSystemActorMsg((ToCalculatedFieldSystemMsg) msg, true); + forwardToTenantActor((ToCalculatedFieldSystemMsg) msg, true); break; case CF_TELEMETRY_MSG: case CF_LINKED_TELEMETRY_MSG: - onToCalculatedFieldSystemActorMsg((ToCalculatedFieldSystemMsg) msg, false); + forwardToTenantActor((ToCalculatedFieldSystemMsg) msg, false); break; default: return false; @@ -162,7 +165,7 @@ public class AppActor extends ContextAwareActor { private void onComponentLifecycleMsg(ComponentLifecycleMsg msg) { TbActorRef target = null; if (TenantId.SYS_TENANT_ID.equals(msg.getTenantId())) { - if (!EntityType.TENANT_PROFILE.equals(msg.getEntityId().getEntityType())) { + if (!msg.getEntityId().getEntityType().isOneOf(EntityType.TENANT_PROFILE, EntityType.TB_RESOURCE)) { log.warn("Message has system tenant id: {}", msg); } } else { @@ -187,7 +190,7 @@ public class AppActor extends ContextAwareActor { } } - private void onToCalculatedFieldSystemActorMsg(ToCalculatedFieldSystemMsg msg, boolean priority) { + private void forwardToTenantActor(TenantAwareMsg msg, boolean priority) { getOrCreateTenantActor(msg.getTenantId()).ifPresentOrElse(tenantActor -> { if (priority) { tenantActor.tellWithHighPriority(msg); @@ -199,21 +202,6 @@ public class AppActor extends ContextAwareActor { }); } - - private void onToDeviceActorMsg(TenantAwareMsg msg, boolean priority) { - getOrCreateTenantActor(msg.getTenantId()).ifPresentOrElse(tenantActor -> { - if (priority) { - tenantActor.tellWithHighPriority(msg); - } else { - tenantActor.tell(msg); - } - }, () -> { - if (msg instanceof TransportToDeviceActorMsgWrapper) { - ((TransportToDeviceActorMsgWrapper) msg).getCallback().onSuccess(); - } - }); - } - private Optional getOrCreateTenantActor(TenantId tenantId) { if (deletedTenants.contains(tenantId)) { return Optional.empty(); @@ -245,6 +233,7 @@ public class AppActor extends ContextAwareActor { public TbActor createActor() { return new AppActor(context); } + } } diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldAlarmActionMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldAlarmActionMsg.java new file mode 100644 index 0000000000..3202296345 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldAlarmActionMsg.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.calculatedField; + +import lombok.Builder; +import lombok.Data; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; +import org.thingsboard.server.common.msg.queue.TbCallback; + +@Data +@Builder +public class CalculatedFieldAlarmActionMsg implements ToCalculatedFieldSystemMsg { + + private final TenantId tenantId; + private final Alarm alarm; + private final ActionType action; + private final TbCallback callback; + + @Override + public MsgType getMsgType() { + return MsgType.CF_ALARM_ACTION_MSG; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldDynamicArgumentsRefreshMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldArgumentResetMsg.java similarity index 79% rename from application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldDynamicArgumentsRefreshMsg.java rename to application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldArgumentResetMsg.java index fdf864611f..8b5927827e 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldDynamicArgumentsRefreshMsg.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldArgumentResetMsg.java @@ -16,22 +16,22 @@ package org.thingsboard.server.actors.calculatedField; import lombok.Data; -import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; @Data -public class EntityCalculatedFieldDynamicArgumentsRefreshMsg implements ToCalculatedFieldSystemMsg { +public class CalculatedFieldArgumentResetMsg implements ToCalculatedFieldSystemMsg { private final TenantId tenantId; - private final CalculatedFieldId cfId; + private final CalculatedFieldCtx ctx; private final TbCallback callback; @Override public MsgType getMsgType() { - return MsgType.CF_ENTITY_DYNAMIC_ARGUMENTS_REFRESH_MSG; + return MsgType.CF_ARGUMENT_RESET_MSG; } } diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActionEventMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActionEventMsg.java new file mode 100644 index 0000000000..6fc191e3db --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActionEventMsg.java @@ -0,0 +1,57 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.calculatedField; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Builder; +import lombok.Data; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.util.ProtoUtils; +import org.thingsboard.server.gen.transport.TransportProtos.EntityActionEventProto; + +@Data +@Builder +public class CalculatedFieldEntityActionEventMsg implements ToCalculatedFieldSystemMsg { + + private final TenantId tenantId; + private final EntityId entityId; + private final JsonNode entity; + private final ActionType action; + private final TbCallback callback; + + public static CalculatedFieldEntityActionEventMsg fromProto(EntityActionEventProto proto, + TbCallback callback) { + return CalculatedFieldEntityActionEventMsg.builder() + .tenantId((TenantId) ProtoUtils.fromProto(proto.getTenantId())) + .entityId(ProtoUtils.fromProto(proto.getEntityId())) + .entity(JacksonUtil.toJsonNode(proto.getEntity())) + .action(ActionType.valueOf(proto.getAction())) + .callback(callback) + .build(); + } + + @Override + public MsgType getMsgType() { + return MsgType.CF_ENTITY_ACTION_EVENT_MSG; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java index 2a5f3c3cfd..160cd995d5 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java @@ -21,6 +21,7 @@ import org.thingsboard.server.actors.TbActorCtx; import org.thingsboard.server.actors.TbActorException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.CalculatedFieldStatePartitionRestoreMsg; import org.thingsboard.server.common.msg.TbActorStopReason; import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; import org.thingsboard.server.common.msg.cf.CalculatedFieldPartitionChangeMsg; @@ -63,20 +64,32 @@ public class CalculatedFieldEntityActor extends AbstractCalculatedFieldActor { case CF_STATE_RESTORE_MSG: processor.process((CalculatedFieldStateRestoreMsg) msg); break; + case CF_STATE_PARTITION_RESTORE_MSG: + processor.process((CalculatedFieldStatePartitionRestoreMsg) msg); + break; case CF_ENTITY_INIT_CF_MSG: processor.process((EntityInitCalculatedFieldMsg) msg); break; case CF_ENTITY_DELETE_MSG: processor.process((CalculatedFieldEntityDeleteMsg) msg); break; + case CF_RELATION_ACTION_MSG: + processor.process((CalculatedFieldRelationActionMsg) msg); + break; case CF_ENTITY_TELEMETRY_MSG: processor.process((EntityCalculatedFieldTelemetryMsg) msg); break; case CF_LINKED_TELEMETRY_MSG: processor.process((EntityCalculatedFieldLinkedTelemetryMsg) msg); break; - case CF_ENTITY_DYNAMIC_ARGUMENTS_REFRESH_MSG: - processor.process((EntityCalculatedFieldDynamicArgumentsRefreshMsg) msg); + case CF_REEVALUATE_MSG: + processor.process((CalculatedFieldReevaluateMsg) msg); + break; + case CF_ALARM_ACTION_MSG: + processor.process((CalculatedFieldAlarmActionMsg) msg); + break; + case CF_ARGUMENT_RESET_MSG: + processor.process((CalculatedFieldArgumentResetMsg) msg); break; default: return false; @@ -88,4 +101,5 @@ public class CalculatedFieldEntityActor extends AbstractCalculatedFieldActor { void logProcessingException(Exception e) { log.warn("[{}][{}] Processing failure", tenantId, processor.entityId, e); } + } diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java index 7513ca41e2..673db74863 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java @@ -21,10 +21,13 @@ import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.DebugModeUtil; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.TbActorCtx; +import org.thingsboard.server.actors.calculatedField.EntityInitCalculatedFieldMsg.StateAction; import org.thingsboard.server.actors.shared.AbstractContextAwareMsgProcessor; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; @@ -33,6 +36,7 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.CalculatedFieldStatePartitionRestoreMsg; import org.thingsboard.server.common.msg.cf.CalculatedFieldPartitionChangeMsg; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TbCallback; @@ -48,7 +52,10 @@ import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; import java.util.ArrayList; import java.util.Collection; @@ -63,6 +70,7 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createStateByType; /** * @author Andrew Shvayka @@ -77,7 +85,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM final CalculatedFieldProcessingService cfService; final CalculatedFieldStateService cfStateService; - TbActorCtx ctx; + TbActorCtx actorCtx; Map states = new HashMap<>(); CalculatedFieldEntityMessageProcessor(ActorSystemContext systemContext, TenantId tenantId, EntityId entityId) { @@ -89,7 +97,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } void init(TbActorCtx ctx) { - this.ctx = ctx; + this.actorCtx = ctx; } public void stop(boolean partitionChanged) { @@ -97,8 +105,9 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM "[{}][{}] Stopping entity actor due to change partition event." : "[{}][{}] Stopping entity actor.", tenantId, entityId); + states.values().forEach(this::closeState); states.clear(); - ctx.stop(ctx.getSelf()); + actorCtx.stop(actorCtx.getSelf()); } public void process(CalculatedFieldPartitionChangeMsg msg) { @@ -110,28 +119,55 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM public void process(CalculatedFieldStateRestoreMsg msg) { CalculatedFieldId cfId = msg.getId().cfId(); log.debug("[{}] [{}] Processing CF state restore msg.", msg.getId().entityId(), cfId); - if (msg.getState() != null) { - states.put(cfId, msg.getState()); + CalculatedFieldState state = msg.getState(); + if (state != null) { + state.setCtx(msg.getCtx(), actorCtx); + state.setPartition(msg.getPartition()); + if (state instanceof RelatedEntitiesAggregationCalculatedFieldState relatedEntitiesAggState) { + relatedEntitiesAggState.scheduleReevaluation(); + } + states.put(cfId, state); } else { - states.remove(cfId); + removeState(cfId); + } + } + + public void process(CalculatedFieldStatePartitionRestoreMsg msg) { + log.debug("Processing CF state partition restore msg: {}", msg); + for (CalculatedFieldState state : states.values()) { + if (msg.getPartition().equals(state.getPartition())) { + state.init(); + } } } public void process(EntityInitCalculatedFieldMsg msg) throws CalculatedFieldException { - log.debug("[{}] Processing entity init CF msg.", msg.getCtx().getCfId()); + log.debug("[{}] Processing entity init CF msg: {}", msg.getCtx().getCfId(), msg); var ctx = msg.getCtx(); - if (msg.isForceReinit()) { - log.debug("Force reinitialization of CF: [{}].", ctx.getCfId()); - states.remove(ctx.getCfId()); + CalculatedFieldState state; + if (msg.getStateAction() == StateAction.RECREATE) { + removeState(ctx.getCfId()); + state = null; + } else { + state = states.get(ctx.getCfId()); } try { - var state = getOrInitState(ctx); + if (state == null) { + state = createState(ctx); + } else if (msg.getStateAction() == StateAction.REINIT) { + log.debug("Force reinitialization of CF: [{}].", ctx.getCfId()); + state.reset(); + initState(state, ctx); + } else { + state.setCtx(ctx, actorCtx); + } if (state.isSizeOk()) { - processStateIfReady(ctx, Collections.singletonList(ctx.getCfId()), state, null, null, msg.getCallback()); + processStateIfReady(state, Collections.emptyMap(), ctx, Collections.singletonList(ctx.getCfId()), null, null, msg.getCallback()); } else { throw new RuntimeException(ctx.getSizeExceedsLimitMessage()); } } catch (Exception e) { + log.debug("[{}][{}] Failed to initialize CF state", entityId, ctx.getCfId(), e); if (e instanceof CalculatedFieldException cfe) { throw cfe; } @@ -139,31 +175,110 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } } - public void process(CalculatedFieldEntityDeleteMsg msg) { + public void process(CalculatedFieldArgumentResetMsg msg) throws CalculatedFieldException { + log.debug("[{}] Processing CF argument reset msg.", entityId); + var ctx = msg.getCtx(); + try { + Map dynamicSourceArgs = ctx.getArguments().entrySet().stream() + .filter(entry -> entry.getValue().hasOwnerSource()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + Map fetchedArgs = cfService.fetchArgsFromDb(tenantId, entityId, dynamicSourceArgs); + fetchedArgs.values().forEach(arg -> arg.setForceResetPrevious(true)); + + processArgumentValuesUpdate(ctx, Collections.singletonList(ctx.getCfId()), msg.getCallback(), fetchedArgs, null, null); + } catch (Exception e) { + throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); + } + } + + public void process(CalculatedFieldEntityDeleteMsg msg) throws CalculatedFieldException { log.debug("[{}] Processing CF entity delete msg.", msg.getEntityId()); if (this.entityId.equals(msg.getEntityId())) { if (states.isEmpty()) { msg.getCallback().onSuccess(); } else { MultipleTbCallback multipleTbCallback = new MultipleTbCallback(states.size(), msg.getCallback()); - states.forEach((cfId, state) -> cfStateService.removeState(new CalculatedFieldEntityCtxId(tenantId, cfId, entityId), multipleTbCallback)); - ctx.stop(ctx.getSelf()); + states.forEach((cfId, state) -> cfStateService.deleteState(new CalculatedFieldEntityCtxId(tenantId, cfId, entityId), multipleTbCallback)); + actorCtx.stop(actorCtx.getSelf()); } } else { var cfId = new CalculatedFieldId(msg.getEntityId().getId()); - var state = states.remove(cfId); + var state = removeState(cfId); if (state != null) { - cfStateService.removeState(new CalculatedFieldEntityCtxId(tenantId, cfId, entityId), msg.getCallback()); + cfStateService.deleteState(new CalculatedFieldEntityCtxId(tenantId, cfId, entityId), msg.getCallback()); } else { msg.getCallback().onSuccess(); } } } + public void process(CalculatedFieldRelationActionMsg msg) throws CalculatedFieldException { + log.debug("[{}] Processing CF {} related entity msg.", msg.getRelatedEntityId(), msg.getAction()); + switch (msg.getAction()) { + case UPDATED -> handleRelationUpdate(msg); + case DELETED -> handleRelationDelete(msg); + default -> msg.getCallback().onSuccess(); + } + } + + private void handleRelationUpdate(CalculatedFieldRelationActionMsg msg) throws CalculatedFieldException { + CalculatedFieldCtx ctx = msg.getCalculatedField(); + var callback = new MultipleTbCallback(CALLBACKS_PER_CF, msg.getCallback()); + var state = states.get(ctx.getCfId()); + try { + Map updatedArgs = new HashMap<>(); + if (state == null) { + state = createState(ctx); + } else { + if (state instanceof RelatedEntitiesAggregationCalculatedFieldState relatedEntitiesAggState) { + Map fetchedArgs = cfService.fetchArgsFromDb(tenantId, msg.getRelatedEntityId(), ctx.getArguments()); + updatedArgs = relatedEntitiesAggState.updateEntityData(setEntityIdToSingleEntityArguments(msg.getRelatedEntityId(), fetchedArgs)); + } + + state.checkStateSize(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId), ctx.getMaxStateSize()); + } + if (state.isSizeOk()) { + processStateIfReady(state, updatedArgs, ctx, Collections.singletonList(ctx.getCfId()), null, null, callback); + } else { + throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).errorMessage(ctx.getSizeExceedsLimitMessage()).build(); + } + } catch (Exception e) { + log.debug("[{}][{}] Failed to initialize CF state", entityId, ctx.getCfId(), e); + if (e instanceof CalculatedFieldException cfe) { + throw cfe; + } + throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); + } + } + + private void handleRelationDelete(CalculatedFieldRelationActionMsg msg) throws CalculatedFieldException { + CalculatedFieldCtx ctx = msg.getCalculatedField(); + CalculatedFieldId cfId = ctx.getCfId(); + CalculatedFieldState state = states.get(cfId); + if (state == null) { + msg.getCallback().onSuccess(); + return; + } + if (state instanceof RelatedEntitiesAggregationCalculatedFieldState aggState) { + aggState.cleanupEntityData(msg.getRelatedEntityId()); + + state.checkStateSize(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId), ctx.getMaxStateSize()); + + if (state.isSizeOk()) { + processStateIfReady(state, Collections.emptyMap(), ctx, Collections.singletonList(ctx.getCfId()), null, null, msg.getCallback()); + } else { + throw new RuntimeException(ctx.getSizeExceedsLimitMessage()); + } + } else { + msg.getCallback().onSuccess(); + } + } + public void process(EntityCalculatedFieldTelemetryMsg msg) throws CalculatedFieldException { - log.debug("[{}] Processing CF telemetry msg.", msg.getEntityId()); + log.trace("[{}] Processing CF telemetry msg: {}", msg.getEntityId(), msg); var proto = msg.getProto(); - var numberOfCallbacks = CALLBACKS_PER_CF * (msg.getEntityIdFields().size() + msg.getProfileIdFields().size()); + var numberOfCallbacks = msg.getEntityIdFields().size() + msg.getProfileIdFields().size(); MultipleTbCallback callback = new MultipleTbCallback(numberOfCallbacks, msg.getCallback()); List cfIdList = getCalculatedFieldIds(proto); Set cfIdSet = new HashSet<>(cfIdList); @@ -176,36 +291,37 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } public void process(EntityCalculatedFieldLinkedTelemetryMsg msg) throws CalculatedFieldException { - log.debug("[{}] Processing CF link telemetry msg.", msg.getEntityId()); + log.trace("[{}] Processing CF link telemetry msg: {}", msg.getEntityId(), msg); var proto = msg.getProto(); var ctx = msg.getCtx(); - var callback = new MultipleTbCallback(CALLBACKS_PER_CF, msg.getCallback()); + var callback = msg.getCallback(); try { List cfIds = getCalculatedFieldIds(proto); if (cfIds.contains(ctx.getCfId())) { - callback.onSuccess(CALLBACKS_PER_CF); + callback.onSuccess(); } else { if (proto.getTsDataCount() > 0) { processArgumentValuesUpdate(ctx, cfIds, callback, mapToArguments(ctx, msg.getEntityId(), proto.getTsDataList()), toTbMsgId(proto), toTbMsgType(proto)); } else if (proto.getAttrDataCount() > 0) { processArgumentValuesUpdate(ctx, cfIds, callback, mapToArguments(ctx, msg.getEntityId(), proto.getScope(), proto.getAttrDataList()), toTbMsgId(proto), toTbMsgType(proto)); } else if (proto.getRemovedTsKeysCount() > 0) { - processArgumentValuesUpdate(ctx, cfIds, callback, mapToArgumentsWithFetchedValue(ctx, proto.getRemovedTsKeysList()), toTbMsgId(proto), toTbMsgType(proto)); + processArgumentValuesUpdate(ctx, cfIds, callback, mapToArgumentsWithFetchedValue(ctx, msg.getEntityId(), proto.getRemovedTsKeysList()), toTbMsgId(proto), toTbMsgType(proto)); } else if (proto.getRemovedAttrKeysCount() > 0) { processArgumentValuesUpdate(ctx, cfIds, callback, mapToArgumentsWithDefaultValue(ctx, msg.getEntityId(), proto.getScope(), proto.getRemovedAttrKeysList()), toTbMsgId(proto), toTbMsgType(proto)); } else { - callback.onSuccess(CALLBACKS_PER_CF); + callback.onSuccess(); } } } catch (Exception e) { + log.debug("[{}][{}] Failed to process linked CF telemetry msg: {}", entityId, ctx.getCfId(), msg, e); throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); } } - private void process(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, Collection cfIds, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException { + private void process(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, Collection cfIds, List cfIdList, TbCallback callback) throws CalculatedFieldException { try { if (cfIds.contains(ctx.getCfId())) { - callback.onSuccess(CALLBACKS_PER_CF); + callback.onSuccess(); } else { if (proto.getTsDataCount() > 0) { processTelemetry(ctx, proto, cfIdList, callback); @@ -216,10 +332,11 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } else if (proto.getRemovedAttrKeysCount() > 0) { processRemovedAttributes(ctx, proto, cfIdList, callback); } else { - callback.onSuccess(CALLBACKS_PER_CF); + callback.onSuccess(); } } } catch (Exception e) { + log.debug("[{}][{}] Failed to process CF telemetry msg: {}", entityId, ctx.getCfId(), proto, e); if (e instanceof CalculatedFieldException cfe) { throw cfe; } @@ -227,92 +344,147 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } } - public void process(EntityCalculatedFieldDynamicArgumentsRefreshMsg msg) throws CalculatedFieldException { - log.debug("[{}][{}] Processing CF dynamic arguments refresh msg.", entityId, msg.getCfId()); - CalculatedFieldState currentState = states.get(msg.getCfId()); - if (currentState == null) { - log.debug("[{}][{}] Failed to find CF state for entity.", entityId, msg.getCfId()); + public void process(CalculatedFieldReevaluateMsg msg) throws CalculatedFieldException { + CalculatedFieldId cfId = msg.getCtx().getCfId(); + CalculatedFieldState state = states.get(cfId); + if (state == null) { + log.debug("[{}][{}] Failed to find CF state for entity to handle {}", entityId, cfId, msg); } else { - currentState.setDirty(true); - log.debug("[{}][{}] CF state marked as dirty.", entityId, msg.getCfId()); + if (state.isSizeOk()) { + log.debug("[{}][{}] Reevaluating CF state", entityId, cfId); + processStateIfReady(state, null, msg.getCtx(), Collections.singletonList(cfId), null, null, msg.getCallback()); + } else { + throw new RuntimeException(msg.getCtx().getSizeExceedsLimitMessage()); + } + } + } + + public void process(CalculatedFieldAlarmActionMsg msg) { + log.debug("[{}] Processing alarm action event msg: {}", entityId, msg); + for (CalculatedFieldState state : states.values()) { + if (state instanceof AlarmCalculatedFieldState alarmCfState) { + Alarm stateAlarm = alarmCfState.getCurrentAlarm(); + if (stateAlarm != null && stateAlarm.getId().equals(msg.getAlarm().getId())) { + alarmCfState.processAlarmAction(msg.getAlarm(), msg.getAction()); + } + } } msg.getCallback().onSuccess(); } - private void processTelemetry(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException { + private void processTelemetry(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, TbCallback callback) throws CalculatedFieldException { processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArguments(ctx, proto.getTsDataList()), toTbMsgId(proto), toTbMsgType(proto)); } - private void processAttributes(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException { + private void processAttributes(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, TbCallback callback) throws CalculatedFieldException { processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArguments(ctx, proto.getScope(), proto.getAttrDataList()), toTbMsgId(proto), toTbMsgType(proto)); } - private void processRemovedTelemetry(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException { - processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArgumentsWithFetchedValue(ctx, proto.getRemovedTsKeysList()), toTbMsgId(proto), toTbMsgType(proto)); + private void processRemovedTelemetry(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, TbCallback callback) throws CalculatedFieldException { + processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArgumentsWithFetchedValue(ctx, entityId, proto.getRemovedTsKeysList()), toTbMsgId(proto), toTbMsgType(proto)); } - private void processRemovedAttributes(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException { + private void processRemovedAttributes(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, TbCallback callback) throws CalculatedFieldException { processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArgumentsWithDefaultValue(ctx, proto.getScope(), proto.getRemovedAttrKeysList()), toTbMsgId(proto), toTbMsgType(proto)); } - private void processArgumentValuesUpdate(CalculatedFieldCtx ctx, List cfIdList, MultipleTbCallback callback, + private void processArgumentValuesUpdate(CalculatedFieldCtx ctx, List cfIdList, TbCallback callback, Map newArgValues, UUID tbMsgId, TbMsgType tbMsgType) throws CalculatedFieldException { if (newArgValues.isEmpty()) { log.debug("[{}] No new argument values to process for CF.", ctx.getCfId()); - callback.onSuccess(CALLBACKS_PER_CF); + callback.onSuccess(); } CalculatedFieldState state = states.get(ctx.getCfId()); boolean justRestored = false; if (state == null) { - state = getOrInitState(ctx); + state = createState(ctx); justRestored = true; - } else if (state.isDirty()) { - log.debug("[{}][{}] Going to update dirty CF state.", entityId, ctx.getCfId()); + } else if (ctx.shouldFetchRelationQueryDynamicArgumentsFromDb(state)) { + log.debug("[{}][{}] Going to update dynamic arguments for CF.", entityId, ctx.getCfId()); try { Map dynamicArgsFromDb = cfService.fetchDynamicArgsFromDb(ctx, entityId); dynamicArgsFromDb.forEach(newArgValues::putIfAbsent); - state.setDirty(false); + if (ctx.getCfType() == CalculatedFieldType.GEOFENCING) { + var geofencingState = (GeofencingCalculatedFieldState) state; + geofencingState.updateLastDynamicArgumentsRefreshTs(); + } + } catch (Exception e) { + throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); + } + } else if (ctx.shouldFetchEntityRelations(state)) { + log.debug("[{}][{}] Going to update related entities for CF.", entityId, ctx.getCfId()); + try { + if (state instanceof RelatedEntitiesAggregationCalculatedFieldState relatedEntitiesState) { + List relatedEntities = cfService.fetchRelatedEntities(ctx, entityId); + List missingEntities = relatedEntitiesState.checkRelatedEntities(relatedEntities); + if (!missingEntities.isEmpty()) { + missingEntities.forEach(missingEntityId -> { + Map fetchedArgs = cfService.fetchArgsFromDb(tenantId, missingEntityId, ctx.getArguments()); + relatedEntitiesState.updateEntityData(setEntityIdToSingleEntityArguments(missingEntityId, fetchedArgs)); + }); + justRestored = true; + } + } } catch (Exception e) { throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); } } if (state.isSizeOk()) { - if (state.updateState(ctx, newArgValues) || justRestored) { + Map updatedArgs = state.update(newArgValues, ctx); + if (!updatedArgs.isEmpty() || justRestored) { cfIdList = new ArrayList<>(cfIdList); cfIdList.add(ctx.getCfId()); - processStateIfReady(ctx, cfIdList, state, tbMsgId, tbMsgType, callback); + processStateIfReady(state, updatedArgs, ctx, cfIdList, tbMsgId, tbMsgType, callback); } else { - callback.onSuccess(CALLBACKS_PER_CF); + callback.onSuccess(); } } else { throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).errorMessage(ctx.getSizeExceedsLimitMessage()).build(); } } - @SneakyThrows - private CalculatedFieldState getOrInitState(CalculatedFieldCtx ctx) { - CalculatedFieldState state = states.get(ctx.getCfId()); - if (state != null) { - return state; - } else { - ListenableFuture stateFuture = cfService.fetchStateFromDb(ctx, entityId); - // Ugly but necessary. We do not expect to often fetch data from DB. Only once per pair lifetime. - // This call happens while processing the CF pack from the queue consumer. So the timeout should be relatively low. - // Alternatively, we can fetch the state outside the actor system and push separate command to create this actor, - // but this will significantly complicate the code. - state = stateFuture.get(1, TimeUnit.MINUTES); - state.checkStateSize(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId), ctx.getMaxStateSize()); - states.put(ctx.getCfId(), state); - } + private CalculatedFieldState createState(CalculatedFieldCtx ctx) { + CalculatedFieldState state = createStateByType(ctx, entityId); + initState(state, ctx); return state; } - private void processStateIfReady(CalculatedFieldCtx ctx, List cfIdList, CalculatedFieldState state, UUID tbMsgId, TbMsgType tbMsgType, TbCallback callback) throws CalculatedFieldException { + private void initState(CalculatedFieldState state, CalculatedFieldCtx ctx) { + state.setCtx(ctx, actorCtx); + state.init(); + + if (ctx.getCfType() == CalculatedFieldType.GEOFENCING && ctx.isRelationQueryDynamicArguments()) { + GeofencingCalculatedFieldState geofencingState = (GeofencingCalculatedFieldState) state; + geofencingState.updateLastDynamicArgumentsRefreshTs(); + } + + Map arguments = fetchArguments(ctx); + state.update(arguments, ctx); + + state.checkStateSize(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId), ctx.getMaxStateSize()); + states.put(ctx.getCfId(), state); + } + + @SneakyThrows + private Map fetchArguments(CalculatedFieldCtx ctx) { + ListenableFuture> argumentsFuture = cfService.fetchArguments(ctx, entityId); + // Ugly but necessary. We do not expect to often fetch data from DB. Only once per pair lifetime. + // This call happens while processing the CF pack from the queue consumer. So the timeout should be relatively low. + // Alternatively, we can fetch the state outside the actor system and push separate command to create this actor, + // but this will significantly complicate the code. + return argumentsFuture.get(1, TimeUnit.MINUTES); + } + + private void processStateIfReady(CalculatedFieldState state, Map updatedArgs, CalculatedFieldCtx ctx, + List cfIdList, UUID tbMsgId, TbMsgType tbMsgType, TbCallback callback) throws CalculatedFieldException { + callback = new MultipleTbCallback(CALLBACKS_PER_CF, callback); + log.trace("[{}][{}] Processing state if ready. Current args: {}, updated args: {}", entityId, ctx.getCfId(), state.getArguments(), updatedArgs); CalculatedFieldEntityCtxId ctxId = new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId); boolean stateSizeChecked = false; try { if (ctx.isInitialized() && state.isReady()) { - CalculatedFieldResult calculationResult = state.performCalculation(entityId, ctx).get(systemContext.getCfCalculationResultTimeout(), TimeUnit.SECONDS); + log.trace("[{}][{}] Performing calculation. Updated args: {}", entityId, ctx.getCfId(), updatedArgs); + CalculatedFieldResult calculationResult = state.performCalculation(updatedArgs, ctx).get(systemContext.getCfCalculationResultTimeout(), TimeUnit.SECONDS); state.checkStateSize(ctxId, ctx.getMaxStateSize()); stateSizeChecked = true; if (state.isSizeOk()) { @@ -322,13 +494,18 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM callback.onSuccess(); } if (DebugModeUtil.isDebugAllAvailable(ctx.getCalculatedField())) { - systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, calculationResult.toStringOrElseNull(), null); + systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, calculationResult.stringValue(), null); } } } else { + if (DebugModeUtil.isDebugFailuresAvailable(ctx.getCalculatedField())) { + String errorMsg = ctx.isInitialized() ? state.getReadinessStatus().errorMsg() : "Calculated field state is not initialized!"; + systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, null, errorMsg); + } callback.onSuccess(); } } catch (Exception e) { + log.debug("[{}][{}] Failed to process CF state", entityId, ctx.getCfId(), e); throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).msgId(tbMsgId).msgType(tbMsgType).arguments(state.getArguments()).cause(e).build(); } finally { if (!stateSizeChecked) { @@ -337,14 +514,30 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM if (state.isSizeOk()) { cfStateService.persistState(ctxId, state, callback); } else { - removeStateAndRaiseSizeException(ctxId, CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).errorMessage(ctx.getSizeExceedsLimitMessage()).build(), callback); + deleteStateAndRaiseSizeException(ctxId, CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).errorMessage(ctx.getSizeExceedsLimitMessage()).build(), callback); + } + } + } + + private CalculatedFieldState removeState(CalculatedFieldId cfId) { + CalculatedFieldState state = states.remove(cfId); + closeState(state); + return state; + } + + private void closeState(CalculatedFieldState state) { + if (state != null) { + try { + state.close(); + } catch (Exception e) { + log.warn("[{}][{}] Failed to close CF state", tenantId, state.getEntityId(), e); } } } - private void removeStateAndRaiseSizeException(CalculatedFieldEntityCtxId ctxId, CalculatedFieldException ex, TbCallback callback) throws CalculatedFieldException { + private void deleteStateAndRaiseSizeException(CalculatedFieldEntityCtxId ctxId, CalculatedFieldException ex, TbCallback callback) throws CalculatedFieldException { // We remove the state, but remember that it is over-sized in a local map. - cfStateService.removeState(ctxId, new TbCallback() { + cfStateService.deleteState(ctxId, new TbCallback() { @Override public void onSuccess() { callback.onFailure(ex); @@ -359,107 +552,164 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } private Map mapToArguments(CalculatedFieldCtx ctx, List data) { - return mapToArguments(ctx.getMainEntityArguments(), data); + return mapToArguments(entityId, ctx.getMainEntityArguments(), Collections.emptyMap(), data); } private Map mapToArguments(CalculatedFieldCtx ctx, EntityId entityId, List data) { - var argNames = ctx.getLinkedEntityArguments().get(entityId); - if (argNames.isEmpty()) { - return Collections.emptyMap(); - } - return mapToArguments(argNames, data); + return mapToArguments(entityId, ctx.getLinkedAndDynamicArgs(entityId), ctx.getRelatedEntityArguments(), data); } - private Map mapToArguments(Map argNames, List data) { - if (argNames.isEmpty()) { - return Collections.emptyMap(); - } + private Map mapToArguments(EntityId originator, Map> args, Map> relatedEntityArgs, List data) { Map arguments = new HashMap<>(); - for (TsKvProto item : data) { - ReferencedEntityKey key = new ReferencedEntityKey(item.getKv().getKey(), ArgumentType.TS_LATEST, null); - String argName = argNames.get(key); - if (argName != null) { - arguments.put(argName, new SingleValueArgumentEntry(item)); - } - key = new ReferencedEntityKey(item.getKv().getKey(), ArgumentType.TS_ROLLING, null); - argName = argNames.get(key); - if (argName != null) { - arguments.put(argName, new SingleValueArgumentEntry(item)); + if (!relatedEntityArgs.isEmpty() || !args.isEmpty()) { + for (TsKvProto item : data) { + ReferencedEntityKey key = new ReferencedEntityKey(item.getKv().getKey(), ArgumentType.TS_LATEST, null); + Set argNames = relatedEntityArgs.get(key); + if (argNames != null) { + argNames.forEach(argName -> { + arguments.put(argName, new SingleValueArgumentEntry(originator, item)); + }); + } + argNames = args.get(key); + if (argNames != null) { + argNames.forEach(argName -> { + arguments.put(argName, new SingleValueArgumentEntry(item)); + }); + } + key = new ReferencedEntityKey(item.getKv().getKey(), ArgumentType.TS_ROLLING, null); + argNames = args.get(key); + if (argNames != null) { + argNames.forEach(argName -> { + arguments.put(argName, new SingleValueArgumentEntry(item)); + }); + } } } return arguments; } private Map mapToArguments(CalculatedFieldCtx ctx, AttributeScopeProto scope, List attrDataList) { - return mapToArguments(entityId, ctx.getMainEntityArguments(), ctx.getMainEntityGeofencingArgumentNames(), scope, attrDataList); + return mapToArguments(entityId, ctx.getMainEntityArguments(), ctx.getMainEntityGeofencingArgumentNames(), Collections.emptyMap(), scope, attrDataList); } private Map mapToArguments(CalculatedFieldCtx ctx, EntityId entityId, AttributeScopeProto scope, List attrDataList) { - var argNames = ctx.getLinkedEntityArguments().get(entityId); - if (argNames.isEmpty()) { - return Collections.emptyMap(); - } - List geofencingArgumentNames = ctx.getLinkedEntityGeofencingArgumentNames(); - return mapToArguments(entityId, argNames, geofencingArgumentNames, scope, attrDataList); + var args = ctx.getLinkedAndDynamicArgs(entityId); + var relatedEntityArgs = ctx.getRelatedEntityArguments(); + List geofencingArgumentNames = ctx.getLinkedEntityAndCurrentOwnerGeofencingArgumentNames(); + return mapToArguments(entityId, args, geofencingArgumentNames, relatedEntityArgs, scope, attrDataList); } - private Map mapToArguments(EntityId entityId, Map argNames, List geoArgNames, AttributeScopeProto scope, List attrDataList) { + private Map mapToArguments(EntityId entityId, Map> args, List geofencingArgNames, Map> relatedEntityArgs, AttributeScopeProto scope, List attrDataList) { + if (args.isEmpty() && relatedEntityArgs.isEmpty()) { + return Collections.emptyMap(); + } Map arguments = new HashMap<>(); for (AttributeValueProto item : attrDataList) { ReferencedEntityKey key = new ReferencedEntityKey(item.getKey(), ArgumentType.ATTRIBUTE, AttributeScope.valueOf(scope.name())); - String argName = argNames.get(key); - if (argName == null) { - continue; + Set argNames = relatedEntityArgs.get(key); + if (argNames != null) { + argNames.forEach(argName -> { + arguments.put(argName, new SingleValueArgumentEntry(entityId, item)); + }); } - if (geoArgNames.contains(argName)) { - arguments.put(argName, new GeofencingArgumentEntry(entityId, item)); + argNames = args.get(key); + if (argNames == null) { continue; } - arguments.put(argName, new SingleValueArgumentEntry(item)); + argNames.forEach(argName -> { + if (geofencingArgNames.contains(argName)) { + arguments.put(argName, new GeofencingArgumentEntry(entityId, item)); + } else { + arguments.put(argName, new SingleValueArgumentEntry(item)); + } + }); } return arguments; } private Map mapToArgumentsWithDefaultValue(CalculatedFieldCtx ctx, EntityId entityId, AttributeScopeProto scope, List removedAttrKeys) { - var argNames = ctx.getLinkedEntityArguments().get(entityId); - if (argNames.isEmpty()) { - return Collections.emptyMap(); - } - return mapToArgumentsWithDefaultValue(argNames, ctx.getArguments(), scope, removedAttrKeys); + var args = ctx.getLinkedAndDynamicArgs(entityId); + var relatedEntityArgs = ctx.getRelatedEntityArguments(); + List geofencingArgumentNames = ctx.getLinkedEntityAndCurrentOwnerGeofencingArgumentNames(); + return mapToArgumentsWithDefaultValue(entityId, args, ctx.getArguments(), geofencingArgumentNames, relatedEntityArgs, scope, removedAttrKeys); } private Map mapToArgumentsWithDefaultValue(CalculatedFieldCtx ctx, AttributeScopeProto scope, List removedAttrKeys) { - return mapToArgumentsWithDefaultValue(ctx.getMainEntityArguments(), ctx.getArguments(), scope, removedAttrKeys); + return mapToArgumentsWithDefaultValue(null, ctx.getMainEntityArguments(), ctx.getArguments(), ctx.getMainEntityGeofencingArgumentNames(), Collections.emptyMap(), scope, removedAttrKeys); } - private Map mapToArgumentsWithDefaultValue(Map argNames, Map configArguments, AttributeScopeProto scope, List removedAttrKeys) { + private Map mapToArgumentsWithDefaultValue(EntityId msgEntityId, + Map> args, + Map configArguments, + List geofencingArgNames, + Map> relatedEntityArgs, + AttributeScopeProto scope, + List removedAttrKeys) { + if (args.isEmpty() && relatedEntityArgs.isEmpty()) { + return Collections.emptyMap(); + } Map arguments = new HashMap<>(); for (String removedKey : removedAttrKeys) { ReferencedEntityKey key = new ReferencedEntityKey(removedKey, ArgumentType.ATTRIBUTE, AttributeScope.valueOf(scope.name())); - String argName = argNames.get(key); - if (argName != null) { - Argument argument = configArguments.get(argName); - String defaultValue = (argument != null) ? argument.getDefaultValue() : null; - arguments.put(argName, StringUtils.isNotEmpty(defaultValue) - ? new SingleValueArgumentEntry(System.currentTimeMillis(), new StringDataEntry(removedKey, defaultValue), null) - : new SingleValueArgumentEntry()); - + Set argNames = relatedEntityArgs.get(key); + if (argNames != null) { + argNames.forEach(argName -> { + String defaultValue = getDefaultValue(configArguments, argName); + SingleValueArgumentEntry argumentEntry = buildSingleValue(removedKey, defaultValue, System.currentTimeMillis()); + arguments.put(argName, new SingleValueArgumentEntry(msgEntityId, argumentEntry)); + }); + } + argNames = args.get(key); + if (argNames == null) { + continue; } + argNames.forEach(argName -> { + if (geofencingArgNames.contains(argName)) { + arguments.put(argName, new GeofencingArgumentEntry()); + } else { + String defaultValue = getDefaultValue(configArguments, argName); + SingleValueArgumentEntry argumentEntry = buildSingleValue(removedKey, defaultValue, System.currentTimeMillis()); + arguments.put(argName, new SingleValueArgumentEntry(argumentEntry)); + } + }); } return arguments; } - private Map mapToArgumentsWithFetchedValue(CalculatedFieldCtx ctx, List removedTelemetryKeys) { + private String getDefaultValue(Map configArguments, String argNames) { + Argument argument = configArguments.get(argNames); + return argument != null ? argument.getDefaultValue() : null; + } + + private SingleValueArgumentEntry buildSingleValue(String attrKey, String defaultValue, long ts) { + return StringUtils.isNotEmpty(defaultValue) + ? new SingleValueArgumentEntry(ts, new StringDataEntry(attrKey, defaultValue), null) + : new SingleValueArgumentEntry(); + } + + private Map mapToArgumentsWithFetchedValue(CalculatedFieldCtx ctx, EntityId entityId, List removedTelemetryKeys) { Map deletedArguments = ctx.getArguments().entrySet().stream() .filter(entry -> removedTelemetryKeys.contains(entry.getValue().getRefEntityKey().getKey())) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); Map fetchedArgs = cfService.fetchArgsFromDb(tenantId, entityId, deletedArguments); + if (CalculatedFieldType.RELATED_ENTITIES_AGGREGATION.equals(ctx.getCfType())) { + fetchedArgs = setEntityIdToSingleEntityArguments(entityId, fetchedArgs); + } fetchedArgs.values().forEach(arg -> arg.setForceResetPrevious(true)); + return fetchedArgs; } + private Map setEntityIdToSingleEntityArguments(EntityId relatedEntityId, Map fetchedArgs) { + return fetchedArgs.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + argEntry -> new SingleValueArgumentEntry(relatedEntityId, argEntry.getValue()) + )); + } + private static List getCalculatedFieldIds(CalculatedFieldTelemetryMsgProto proto) { List cfIds = new LinkedList<>(); for (var cfId : proto.getPreviousCalculatedFieldsList()) { diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldLinkedTelemetryMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldLinkedTelemetryMsg.java index 3e0fba2627..f92ed2ca9e 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldLinkedTelemetryMsg.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldLinkedTelemetryMsg.java @@ -22,7 +22,6 @@ import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto; -import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; @Data public class CalculatedFieldLinkedTelemetryMsg implements ToCalculatedFieldSystemMsg { @@ -32,9 +31,9 @@ public class CalculatedFieldLinkedTelemetryMsg implements ToCalculatedFieldSyste private final CalculatedFieldLinkedTelemetryMsgProto proto; private final TbCallback callback; - @Override public MsgType getMsgType() { return MsgType.CF_LINKED_TELEMETRY_MSG; } + } diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java index ab6cb34176..80daff07ef 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java @@ -20,6 +20,7 @@ import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.TbActorCtx; import org.thingsboard.server.actors.TbActorException; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.CalculatedFieldStatePartitionRestoreMsg; import org.thingsboard.server.common.msg.TbActorStopReason; import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; import org.thingsboard.server.common.msg.cf.CalculatedFieldCacheInitMsg; @@ -70,18 +71,21 @@ public class CalculatedFieldManagerActor extends AbstractCalculatedFieldActor { case CF_STATE_RESTORE_MSG: processor.onStateRestoreMsg((CalculatedFieldStateRestoreMsg) msg); break; + case CF_STATE_PARTITION_RESTORE_MSG: + processor.onStatePartitionRestoreMsg((CalculatedFieldStatePartitionRestoreMsg) msg); + break; case CF_ENTITY_LIFECYCLE_MSG: processor.onEntityLifecycleMsg((CalculatedFieldEntityLifecycleMsg) msg); break; + case CF_ENTITY_ACTION_EVENT_MSG: + processor.onEntityActionEventMsg((CalculatedFieldEntityActionEventMsg) msg); + break; case CF_TELEMETRY_MSG: processor.onTelemetryMsg((CalculatedFieldTelemetryMsg) msg); break; case CF_LINKED_TELEMETRY_MSG: processor.onLinkedTelemetryMsg((CalculatedFieldLinkedTelemetryMsg) msg); break; - case CF_DYNAMIC_ARGUMENTS_REFRESH_MSG: - processor.onDynamicArgumentsRefreshMsg((CalculatedFieldDynamicArgumentsRefreshMsg) msg); - break; default: return false; } diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java index 75ca0b4c9b..ccdf719f6c 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java @@ -16,24 +16,39 @@ package org.thingsboard.server.actors.calculatedField; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.function.TriConsumer; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.TbActorCtx; import org.thingsboard.server.actors.TbActorRef; import org.thingsboard.server.actors.TbCalculatedFieldEntityActorId; +import org.thingsboard.server.actors.calculatedField.EntityInitCalculatedFieldMsg.StateAction; import org.thingsboard.server.actors.service.DefaultActorService; import org.thingsboard.server.actors.shared.AbstractContextAwareMsgProcessor; +import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ProfileEntityIdInfo; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; -import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageDataIterable; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; +import org.thingsboard.server.common.msg.CalculatedFieldStatePartitionRestoreMsg; import org.thingsboard.server.common.msg.cf.CalculatedFieldCacheInitMsg; import org.thingsboard.server.common.msg.cf.CalculatedFieldEntityLifecycleMsg; import org.thingsboard.server.common.msg.cf.CalculatedFieldPartitionChangeMsg; @@ -42,10 +57,13 @@ import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.cf.CalculatedFieldService; +import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.queue.settings.TbQueueCalculatedFieldSettings; import org.thingsboard.server.service.cf.CalculatedFieldProcessingService; import org.thingsboard.server.service.cf.CalculatedFieldStateService; +import org.thingsboard.server.service.cf.OwnerService; import org.thingsboard.server.service.cf.cache.TenantEntityProfileCache; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; @@ -55,13 +73,16 @@ import org.thingsboard.server.service.profile.TbDeviceProfileCache; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; +import java.util.function.Function; import static org.thingsboard.server.utils.CalculatedFieldUtils.fromProto; @@ -74,16 +95,20 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware private final Map calculatedFields = new HashMap<>(); private final Map> entityIdCalculatedFields = new HashMap<>(); private final Map> entityIdCalculatedFieldLinks = new HashMap<>(); - private final Map> cfDynamicArgumentsRefreshTasks = new ConcurrentHashMap<>(); + private final Map> ownerEntities = new HashMap<>(); + private ScheduledFuture cfsReevaluationTask; private final CalculatedFieldProcessingService cfExecService; private final CalculatedFieldStateService cfStateService; private final CalculatedFieldService cfDaoService; private final DeviceService deviceService; private final AssetService assetService; + private final CustomerService customerService; + private final RelationService relationService; private final TbAssetProfileCache assetProfileCache; private final TbDeviceProfileCache deviceProfileCache; private final TenantEntityProfileCache entityProfileCache; + private final OwnerService ownerService; private final TbQueueCalculatedFieldSettings cfSettings; protected final TenantId tenantId; @@ -96,9 +121,12 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware this.cfDaoService = systemContext.getCalculatedFieldService(); this.deviceService = systemContext.getDeviceService(); this.assetService = systemContext.getAssetService(); + this.customerService = systemContext.getCustomerService(); + this.relationService = systemContext.getRelationService(); this.assetProfileCache = systemContext.getAssetProfileCache(); this.deviceProfileCache = systemContext.getDeviceProfileCache(); this.entityProfileCache = new TenantEntityProfileCache(); + this.ownerService = systemContext.getOwnerService(); this.cfSettings = systemContext.getCalculatedFieldSettings(); this.tenantId = tenantId; } @@ -109,92 +137,108 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware public void stop() { log.info("[{}] Stopping CF manager actor.", tenantId); - calculatedFields.values().forEach(CalculatedFieldCtx::stop); + calculatedFields.values().forEach(CalculatedFieldCtx::close); calculatedFields.clear(); entityIdCalculatedFields.clear(); entityIdCalculatedFieldLinks.clear(); - cfDynamicArgumentsRefreshTasks.values().forEach(future -> future.cancel(true)); - cfDynamicArgumentsRefreshTasks.clear(); + if (cfsReevaluationTask != null) { + cfsReevaluationTask.cancel(true); + cfsReevaluationTask = null; + } ctx.stop(ctx.getSelf()); } public void onCacheInitMsg(CalculatedFieldCacheInitMsg msg) { log.debug("[{}] Processing CF actor init message.", msg.getTenantId().getId()); - initEntityProfileCache(); + initEntitiesCache(); initCalculatedFields(); + scheduleCfsReevaluation(); msg.getCallback().onSuccess(); } public void onStateRestoreMsg(CalculatedFieldStateRestoreMsg msg) { var cfId = msg.getId().cfId(); - var calculatedField = calculatedFields.get(cfId); + var ctx = calculatedFields.get(cfId); - if (calculatedField != null) { - if (msg.getState() != null) { - msg.getState().setRequiredArguments(calculatedField.getArgNames()); - } + if (ctx != null) { + msg.setCtx(ctx); log.debug("Pushing CF state restore msg to specific actor [{}]", msg.getId().entityId()); getOrCreateActor(msg.getId().entityId()).tell(msg); } else { - cfStateService.removeState(msg.getId(), msg.getCallback()); + cfStateService.deleteState(msg.getId(), msg.getCallback()); } } + public void onStatePartitionRestoreMsg(CalculatedFieldStatePartitionRestoreMsg msg) { + ctx.broadcastToChildren(msg, true); + } + + private void scheduleCfsReevaluation() { + cfsReevaluationTask = systemContext.getScheduler().scheduleWithFixedDelay(() -> { + try { + calculatedFields.values().forEach(cf -> { + if (cf.isRequiresScheduledReevaluation()) { + applyToTargetCfEntityActors(cf, TbCallback.EMPTY, (entityId, callback) -> { + log.debug("[{}][{}] Pushing scheduled CF reevaluate msg", entityId, cf.getCfId()); + getOrCreateActor(entityId).tell(new CalculatedFieldReevaluateMsg(tenantId, cf)); + }); + } + }); + } catch (Exception e) { + log.warn("[{}] Failed to trigger CFs reevaluation", tenantId, e); + } + }, systemContext.getAlarmRulesReevaluationInterval(), systemContext.getAlarmRulesReevaluationInterval(), TimeUnit.SECONDS); + } + public void onEntityLifecycleMsg(CalculatedFieldEntityLifecycleMsg msg) throws CalculatedFieldException { - log.debug("Processing entity lifecycle event: [{}] for entity: [{}]", msg.getData().getEvent(), msg.getData().getEntityId()); - var entityType = msg.getData().getEntityId().getEntityType(); var event = msg.getData().getEvent(); + if (ComponentLifecycleEvent.RELATION_UPDATED.equals(event) || ComponentLifecycleEvent.RELATION_DELETED.equals(event)) { + log.debug("Processing relation [{}] event from entity: [{}]", event, msg.getData().getEntityId()); + onRelationChangedEvent(msg.getData(), msg.getCallback()); + return; + } + log.debug("Processing entity lifecycle event: [{}] for entity: [{}]", event, msg.getData().getEntityId()); + var entityType = msg.getData().getEntityId().getEntityType(); switch (entityType) { - case CALCULATED_FIELD: { + case CALCULATED_FIELD -> { switch (event) { - case CREATED: - onCfCreated(msg.getData(), msg.getCallback()); - break; - case UPDATED: - onCfUpdated(msg.getData(), msg.getCallback()); - break; - case DELETED: - onCfDeleted(msg.getData(), msg.getCallback()); - break; - default: - msg.getCallback().onSuccess(); - break; + case CREATED -> onCfCreated(msg.getData(), msg.getCallback()); + case UPDATED -> onCfUpdated(msg.getData(), msg.getCallback()); + case DELETED -> onCfDeleted(msg.getData(), msg.getCallback()); + default -> msg.getCallback().onSuccess(); } - break; } - case DEVICE: - case ASSET: { + case DEVICE, ASSET, CUSTOMER -> { switch (event) { - case CREATED: - onEntityCreated(msg.getData(), msg.getCallback()); - break; - case UPDATED: - onEntityUpdated(msg.getData(), msg.getCallback()); - break; - case DELETED: - onEntityDeleted(msg.getData(), msg.getCallback()); - break; - default: - msg.getCallback().onSuccess(); - break; + case CREATED -> onEntityCreated(msg.getData(), msg.getCallback()); + case UPDATED -> onEntityUpdated(msg.getData(), msg.getCallback()); + case DELETED -> onEntityDeleted(msg.getData(), msg.getCallback()); + default -> msg.getCallback().onSuccess(); } - break; } - case DEVICE_PROFILE: - case ASSET_PROFILE: { + case DEVICE_PROFILE, ASSET_PROFILE -> { switch (event) { - case DELETED: - onProfileDeleted(msg.getData(), msg.getCallback()); - break; - default: - msg.getCallback().onSuccess(); - break; + case DELETED -> onProfileDeleted(msg.getData(), msg.getCallback()); + default -> msg.getCallback().onSuccess(); } - break; } - default: { - msg.getCallback().onSuccess(); + default -> msg.getCallback().onSuccess(); + } + } + + public void onEntityActionEventMsg(CalculatedFieldEntityActionEventMsg msg) { + switch (msg.getAction()) { + case ALARM_ACK, ALARM_CLEAR, ALARM_DELETE -> { + Alarm alarm = JacksonUtil.treeToValue(msg.getEntity(), Alarm.class); + CalculatedFieldAlarmActionMsg alarmActionMsg = CalculatedFieldAlarmActionMsg.builder() + .tenantId(tenantId) + .alarm(alarm) + .action(msg.getAction()) + .callback(msg.getCallback()) + .build(); + getOrCreateActor(alarm.getOriginator()).tellWithHighPriority(alarmActionMsg); } + default -> msg.getCallback().onSuccess(); } } @@ -209,6 +253,8 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware if (profileId != null) { entityProfileCache.add(profileId, entityId); } + updateEntityOwner(entityId); + if (!isMyPartition(entityId, callback)) { return; } @@ -217,8 +263,8 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware var fieldsCount = entityIdFields.size() + profileIdFields.size(); if (fieldsCount > 0) { MultipleTbCallback multiCallback = new MultipleTbCallback(fieldsCount, callback); - entityIdFields.forEach(ctx -> initCfForEntity(entityId, ctx, true, multiCallback)); - profileIdFields.forEach(ctx -> initCfForEntity(entityId, ctx, true, multiCallback)); + entityIdFields.forEach(ctx -> initCfForEntity(entityId, ctx, StateAction.INIT, multiCallback)); + profileIdFields.forEach(ctx -> initCfForEntity(entityId, ctx, StateAction.INIT, multiCallback)); } else { callback.onSuccess(); } @@ -237,21 +283,84 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware MultipleTbCallback multiCallback = new MultipleTbCallback(fieldsCount, callback); var entityId = msg.getEntityId(); oldProfileCfs.forEach(ctx -> deleteCfForEntity(entityId, ctx.getCfId(), multiCallback)); - newProfileCfs.forEach(ctx -> initCfForEntity(entityId, ctx, true, multiCallback)); + newProfileCfs.forEach(ctx -> initCfForEntity(entityId, ctx, StateAction.INIT, multiCallback)); } else { callback.onSuccess(); } + } else if (msg.isOwnerChanged()) { + onEntityOwnerChanged(msg, callback); + } else { + callback.onSuccess(); } } private void onEntityDeleted(ComponentLifecycleMsg msg, TbCallback callback) { - entityProfileCache.removeEntityId(msg.getEntityId()); + switch (msg.getEntityId().getEntityType()) { + case DEVICE, ASSET -> entityProfileCache.removeEntityId(msg.getEntityId()); + case CUSTOMER -> ownerEntities.remove(msg.getEntityId()); + } + ownerEntities.values().forEach(entities -> entities.remove(msg.getEntityId())); if (isMyPartition(msg.getEntityId(), callback)) { log.debug("Pushing entity lifecycle msg to specific actor [{}]", msg.getEntityId()); getOrCreateActor(msg.getEntityId()).tell(new CalculatedFieldEntityDeleteMsg(tenantId, msg.getEntityId(), callback)); } } + private void onRelationChangedEvent(ComponentLifecycleMsg msg, TbCallback callback) { + Function> relationAction = switch (msg.getEvent()) { + case RELATION_UPDATED -> relatedId -> (entityId, ctx, cb) -> initRelatedEntity(entityId, relatedId, ctx, cb); + case RELATION_DELETED -> relatedId -> (entityId, ctx, cb) -> deleteRelatedEntity(entityId, relatedId, ctx, cb); + default -> null; + }; + + if (relationAction == null) { + callback.onSuccess(); + return; + } + + EntityRelation entityRelation = JacksonUtil.treeToValue(msg.getInfo(), EntityRelation.class); + EntityId toId = entityRelation.getTo(); + EntityId fromId = entityRelation.getFrom(); + String relationType = entityRelation.getType(); + + if (!(CalculatedField.isSupportedRefEntity(toId) || CalculatedField.isSupportedRefEntity(fromId))) { + callback.onSuccess(); + return; + } + + MultipleTbCallback callbackForToAndFrom = new MultipleTbCallback(2, callback); + processRelationByDirection(EntitySearchDirection.TO, relationType, toId, callbackForToAndFrom, relationAction.apply(fromId)); + processRelationByDirection(EntitySearchDirection.FROM, relationType, fromId, callbackForToAndFrom, relationAction.apply(toId)); + } + + private void processRelationByDirection(EntitySearchDirection direction, + String relationType, + EntityId mainId, + MultipleTbCallback parentCallback, + TriConsumer relationAction) { + List cfsByEntityIdAndProfile = getCalculatedFieldsByEntityIdAndProfile(mainId); + if (cfsByEntityIdAndProfile.isEmpty()) { + parentCallback.onSuccess(); + return; + } + + List matchingCfs = cfsByEntityIdAndProfile.stream() + .filter(cf -> { + if (cf.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration config) { + RelationPathLevel relation = config.getRelation(); + return direction.equals(relation.direction()) && relationType.equals(relation.relationType()); + } + return false; + }) + .toList(); + + MultipleTbCallback directionCallback = new MultipleTbCallback(matchingCfs.size(), parentCallback); + + matchingCfs.forEach(ctx -> + applyToTargetCfEntityActors(ctx, directionCallback, (entityId, cb) -> relationAction.accept(entityId, ctx, cb)) + ); + } + private void onCfCreated(ComponentLifecycleMsg msg, TbCallback callback) throws CalculatedFieldException { var cfId = new CalculatedFieldId(msg.getEntityId().getId()); if (calculatedFields.containsKey(cfId)) { @@ -274,14 +383,13 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx); addLinks(cf); - scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(cfCtx); - applyToTargetCfEntityActors(cfCtx, callback, (id, cb) -> initCfForEntity(id, cfCtx, false, cb)); + applyToTargetCfEntityActors(cfCtx, callback, (id, cb) -> initCfForEntity(id, cfCtx, StateAction.INIT, cb)); } } } private CalculatedFieldCtx getCfCtx(CalculatedField cf) { - return new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService(), systemContext.getRelationService()); + return new CalculatedFieldCtx(cf, systemContext); } private void onCfUpdated(ComponentLifecycleMsg msg, TbCallback callback) throws CalculatedFieldException { @@ -300,42 +408,54 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware newCfCtx.init(); } catch (Exception e) { throw CalculatedFieldException.builder().ctx(newCfCtx).eventEntity(newCfCtx.getEntityId()).cause(e).errorMessage("Failed to initialize CF context").build(); - } - calculatedFields.put(newCf.getId(), newCfCtx); - List oldCfList = entityIdCalculatedFields.get(newCf.getEntityId()); - - boolean hasSchedulingConfigChanges = newCfCtx.hasSchedulingConfigChanges(oldCfCtx); - if (hasSchedulingConfigChanges) { - cancelCfDynamicArgumentsRefreshTaskIfExists(cfId, false); - scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(newCfCtx); - } - - List newCfList = new CopyOnWriteArrayList<>(); - boolean found = false; - for (CalculatedFieldCtx oldCtx : oldCfList) { - if (oldCtx.getCfId().equals(newCf.getId())) { + } finally { + calculatedFields.put(newCf.getId(), newCfCtx); + List oldCfList = entityIdCalculatedFields.get(newCf.getEntityId()); + List newCfList = new CopyOnWriteArrayList<>(); + boolean found = false; + for (CalculatedFieldCtx oldCtx : oldCfList) { + if (oldCtx.getCfId().equals(newCf.getId())) { + newCfList.add(newCfCtx); + found = true; + } else { + newCfList.add(oldCtx); + } + } + if (!found) { newCfList.add(newCfCtx); - found = true; - } else { - newCfList.add(oldCtx); } + // We use copy on write lists to safely pass the reference to another actor for the iteration. + // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) + entityIdCalculatedFields.put(newCf.getEntityId(), newCfList); + deleteLinks(oldCfCtx); + addLinks(newCf); } - if (!found) { - newCfList.add(newCfCtx); - } - entityIdCalculatedFields.put(newCf.getEntityId(), newCfList); - - deleteLinks(oldCfCtx); - addLinks(newCf); - // We use copy on write lists to safely pass the reference to another actor for the iteration. - // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) - var stateChanges = newCfCtx.hasStateChanges(oldCfCtx); - if (stateChanges || newCfCtx.hasOtherSignificantChanges(oldCfCtx)) { - applyToTargetCfEntityActors(newCfCtx, callback, (id, cb) -> initCfForEntity(id, newCfCtx, stateChanges, cb)); + StateAction stateAction; + if (newCfCtx.getCfType() != oldCfCtx.getCfType()) { + stateAction = StateAction.RECREATE; // completely recreate state, then calculate + } else if (newCfCtx.hasStateChanges(oldCfCtx)) { + stateAction = StateAction.REINIT; // refetch arguments, call state.init, then calculate + } else if (newCfCtx.hasContextOnlyChanges(oldCfCtx)) { + stateAction = StateAction.REPROCESS; // call state.setCtx, then calculate } else { callback.onSuccess(); + return; } + + applyToTargetCfEntityActors(newCfCtx, new TbCallback() { + @Override + public void onSuccess() { + oldCfCtx.close(); + callback.onSuccess(); + } + + @Override + public void onFailure(Throwable t) { + oldCfCtx.close(); + callback.onFailure(t); + } + }, (id, cb) -> initCfForEntity(id, newCfCtx, stateAction, cb)); } } } @@ -350,24 +470,26 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware } entityIdCalculatedFields.get(cfCtx.getEntityId()).remove(cfCtx); deleteLinks(cfCtx); - cancelCfDynamicArgumentsRefreshTaskIfExists(cfId, true); - applyToTargetCfEntityActors(cfCtx, callback, (id, cb) -> deleteCfForEntity(id, cfId, cb)); - } + applyToTargetCfEntityActors(cfCtx, new TbCallback() { + @Override + public void onSuccess() { + cfCtx.close(); + callback.onSuccess(); + } - private void cancelCfDynamicArgumentsRefreshTaskIfExists(CalculatedFieldId cfId, boolean cfDeleted) { - var existingTask = cfDynamicArgumentsRefreshTasks.remove(cfId); - if (existingTask != null) { - existingTask.cancel(false); - String reason = cfDeleted ? "deletion" : "update"; - log.debug("[{}][{}] Cancelled dynamic arguments refresh task due to CF {}!", tenantId, cfId, reason); - } + @Override + public void onFailure(Throwable t) { + cfCtx.close(); + callback.onFailure(t); + } + }, (id, cb) -> deleteCfForEntity(id, cfId, cb)); } public void onTelemetryMsg(CalculatedFieldTelemetryMsg msg) { EntityId entityId = msg.getEntityId(); log.debug("Received telemetry msg from entity [{}]", entityId); - // 2 = 1 for CF processing + 1 for links processing - MultipleTbCallback callback = new MultipleTbCallback(2, msg.getCallback()); + // 4 = 1 for CF processing + 1 for links processing + 1 for owner entity processing + 1 for aggregation processing + MultipleTbCallback callback = new MultipleTbCallback(4, msg.getCallback()); // process all cfs related to entity, or it's profile; var entityIdFields = getCalculatedFieldsByEntityId(entityId); var profileIdFields = getCalculatedFieldsByEntityId(getProfileId(tenantId, entityId)); @@ -385,6 +507,60 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware } else { callback.onSuccess(); } + // process all cfs related to owner entity + if (entityId.getEntityType().isOneOf(EntityType.TENANT, EntityType.CUSTOMER)) { + List ownedEntitiesCFs = filterOwnedEntitiesCFs(msg); + if (!ownedEntitiesCFs.isEmpty()) { + cfExecService.pushMsgToLinks(msg, ownedEntitiesCFs, callback); + } else { + callback.onSuccess(); + } + } else { + callback.onSuccess(); + } + // process all aggregation cfs (if any); + List aggregationCalculatedFields = filterAggregationCfs(msg); + if (!aggregationCalculatedFields.isEmpty()) { + cfExecService.pushMsgToLinks(msg, aggregationCalculatedFields, callback); + } else { + callback.onSuccess(); + } + } + + private List filterAggregationCfs(CalculatedFieldTelemetryMsg msg) { + EntityId entityId = msg.getEntityId(); + return calculatedFields.values().stream() + .filter(cf -> CalculatedFieldType.RELATED_ENTITIES_AGGREGATION.equals(cf.getCfType())) + .filter(cf -> cf.relatedEntityMatches(msg.getProto())) + .flatMap(cf -> findRelationsForCf(entityId, cf).stream()) + .toList(); + } + + private List findRelationsForCf(EntityId entityId, CalculatedFieldCtx cf) { + List result = new ArrayList<>(); + if (cf.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration configuration) { + RelationPathLevel relation = configuration.getRelation(); + EntitySearchDirection inverseDirection = switch (relation.direction()) { + case FROM -> EntitySearchDirection.TO; + case TO -> EntitySearchDirection.FROM; + }; + RelationPathLevel inverseRelation = new RelationPathLevel(inverseDirection, relation.relationType()); + List byRelationPathQuery = relationService.findByRelationPathQuery(tenantId, new EntityRelationPathQuery(entityId, List.of(inverseRelation))); + if (byRelationPathQuery != null && !byRelationPathQuery.isEmpty()) { + switch (relation.direction()) { + case FROM -> { + EntityRelation entityRelation = byRelationPathQuery.get(0); // only one supported + result.add(new CalculatedFieldEntityCtxId(tenantId, cf.getCfId(), entityRelation.getFrom())); + } + case TO -> { + byRelationPathQuery.stream() + .filter(entityRelation -> entityRelation.getTo().equals(cf.getEntityId())) + .forEach(entityRelation -> result.add(new CalculatedFieldEntityCtxId(tenantId, cf.getCfId(), entityRelation.getTo()))); + } + } + } + } + return result; } public void onLinkedTelemetryMsg(CalculatedFieldLinkedTelemetryMsg msg) { @@ -407,12 +583,35 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware } } + private void onEntityOwnerChanged(ComponentLifecycleMsg msg, TbCallback msgCallback) { + EntityId entityId = msg.getEntityId(); + log.debug("Received changed owner msg from entity [{}]", entityId); + updateEntityOwner(entityId); + List cfs = getCalculatedFieldsByEntityIdAndProfile(entityId); + if (cfs.isEmpty()) { + msgCallback.onSuccess(); + return; + } + MultipleTbCallback callback = new MultipleTbCallback(cfs.size(), msgCallback); + cfs.forEach(cf -> { + if (isMyPartition(entityId, callback)) { + if (cf.hasCurrentOwnerSourceArguments()) { + CalculatedFieldArgumentResetMsg argResetMsg = new CalculatedFieldArgumentResetMsg(tenantId, cf, callback); + log.debug("Pushing CF argument reset msg to specific actor [{}]", entityId); + getOrCreateActor(entityId).tell(argResetMsg); + } else { + callback.onSuccess(); + } + } + }); + } + private List filterCalculatedFieldLinks(CalculatedFieldTelemetryMsg msg) { EntityId entityId = msg.getEntityId(); var proto = msg.getProto(); List result = new ArrayList<>(); for (var link : getCalculatedFieldLinksByEntityId(entityId)) { - CalculatedFieldCtx ctx = calculatedFields.get(link.getCalculatedFieldId()); + CalculatedFieldCtx ctx = calculatedFields.get(link.calculatedFieldId()); if (ctx.linkMatches(entityId, proto)) { result.add(ctx.toCalculatedFieldEntityCtxId()); } @@ -420,6 +619,27 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware return result; } + private List filterOwnedEntitiesCFs(CalculatedFieldTelemetryMsg msg) { + Set entities = getOwnedEntities(msg.getEntityId()); + var proto = msg.getProto(); + List result = new ArrayList<>(); + for (var entityId : entities) { + var ownerEntityCFs = getCalculatedFieldsByEntityId(entityId); + for (var ctx : ownerEntityCFs) { + if (ctx.dynamicSourceMatches(proto)) { + result.add(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId)); + } + } + var ownerEntityProfileCFs = getCalculatedFieldsByEntityId(getProfileId(tenantId, entityId)); + for (var ctx : ownerEntityProfileCFs) { + if (ctx.dynamicSourceMatches(proto)) { + result.add(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId)); + } + } + } + return result; + } + private List getCalculatedFieldsByEntityId(EntityId entityId) { if (entityId == null) { return Collections.emptyList(); @@ -431,6 +651,16 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware return result; } + private List getCalculatedFieldsByEntityIdAndProfile(EntityId entityId) { + List cfsByEntityIdAndProfile = new ArrayList<>(); + cfsByEntityIdAndProfile.addAll(getCalculatedFieldsByEntityId(entityId)); + EntityId profileId = getProfileId(tenantId, entityId); + if (profileId != null) { + cfsByEntityIdAndProfile.addAll(getCalculatedFieldsByEntityId(profileId)); + } + return cfsByEntityIdAndProfile; + } + private List getCalculatedFieldLinksByEntityId(EntityId entityId) { if (entityId == null) { return Collections.emptyList(); @@ -442,46 +672,30 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware return result; } - private void scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(CalculatedFieldCtx cfCtx) { - CalculatedField cf = cfCtx.getCalculatedField(); - if (!(cf.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledCfConfig)) { - return; - } - if (!scheduledCfConfig.isScheduledUpdateEnabled()) { - return; + private Set getOwnedEntities(EntityId entityId) { + if (entityId == null) { + return Collections.emptySet(); } - if (cfDynamicArgumentsRefreshTasks.containsKey(cf.getId())) { - log.debug("[{}][{}] Dynamic arguments refresh task for CF already exists!", tenantId, cf.getId()); - return; + var result = ownerEntities.get(entityId); + if (result == null) { + result = Collections.emptySet(); } - long refreshDynamicSourceInterval = TimeUnit.SECONDS.toMillis(scheduledCfConfig.getScheduledUpdateInterval()); - var scheduledMsg = new CalculatedFieldDynamicArgumentsRefreshMsg(tenantId, cfCtx.getCfId()); - - ScheduledFuture scheduledFuture = systemContext - .schedulePeriodicMsgWithDelay(ctx, scheduledMsg, refreshDynamicSourceInterval, refreshDynamicSourceInterval); - cfDynamicArgumentsRefreshTasks.put(cf.getId(), scheduledFuture); - log.debug("[{}][{}] Scheduled dynamic arguments refresh task for CF!", tenantId, cf.getId()); + return result; } - public void onDynamicArgumentsRefreshMsg(CalculatedFieldDynamicArgumentsRefreshMsg msg) { - log.debug("[{}] [{}] Processing CF dynamic arguments refresh task.", tenantId, msg.getCfId()); - CalculatedFieldCtx cfCtx = calculatedFields.get(msg.getCfId()); - if (cfCtx == null) { - log.debug("[{}][{}] Failed to find CF context, going to stop dynamic arguments refresh task for CF.", tenantId, msg.getCfId()); - cancelCfDynamicArgumentsRefreshTaskIfExists(msg.getCfId(), true); - return; - } - applyToTargetCfEntityActors(cfCtx, msg.getCallback(), (id, cb) -> refreshDynamicArgumentsForEntity(id, msg.getCfId(), cb)); + private void linkedTelemetryMsgForEntity(EntityId entityId, EntityCalculatedFieldLinkedTelemetryMsg msg) { + log.debug("Pushing linked telemetry msg to specific actor [{}]", entityId); + getOrCreateActor(entityId).tell(msg); } - private void refreshDynamicArgumentsForEntity(EntityId entityId, CalculatedFieldId cfId, TbCallback callback) { - log.debug("Pushing CF dynamic arguments refresh msg to specific actor [{}]", entityId); - getOrCreateActor(entityId).tell(new EntityCalculatedFieldDynamicArgumentsRefreshMsg(tenantId, cfId, callback)); + private void deleteRelatedEntity(EntityId entityId, EntityId relatedEntityId, CalculatedFieldCtx cf, TbCallback callback) { + log.debug("Pushing delete related entity msg to specific actor [{}]", relatedEntityId); + getOrCreateActor(entityId).tell(new CalculatedFieldRelationActionMsg(tenantId, relatedEntityId, ActionType.DELETED, cf, callback)); } - private void linkedTelemetryMsgForEntity(EntityId entityId, EntityCalculatedFieldLinkedTelemetryMsg msg) { - log.debug("Pushing linked telemetry msg to specific actor [{}]", entityId); - getOrCreateActor(entityId).tell(msg); + private void initRelatedEntity(EntityId entityId, EntityId relatedEntityId, CalculatedFieldCtx cf, TbCallback callback) { + log.debug("Pushing init related entity msg to specific actor [{}]", relatedEntityId); + getOrCreateActor(entityId).tell(new CalculatedFieldRelationActionMsg(tenantId, relatedEntityId, ActionType.UPDATED, cf, callback)); } private void deleteCfForEntity(EntityId entityId, CalculatedFieldId cfId, TbCallback callback) { @@ -489,9 +703,9 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware getOrCreateActor(entityId).tell(new CalculatedFieldEntityDeleteMsg(tenantId, cfId, callback)); } - private void initCfForEntity(EntityId entityId, CalculatedFieldCtx cfCtx, boolean forceStateReinit, TbCallback callback) { + private void initCfForEntity(EntityId entityId, CalculatedFieldCtx cfCtx, StateAction stateAction, TbCallback callback) { log.debug("Pushing entity init CF msg to specific actor [{}]", entityId); - getOrCreateActor(entityId).tell(new EntityInitCalculatedFieldMsg(tenantId, cfCtx, callback, forceStateReinit)); + getOrCreateActor(entityId).tell(new EntityInitCalculatedFieldMsg(tenantId, cfCtx, stateAction, callback)); } private boolean isMyPartition(EntityId entityId, TbCallback callback) { @@ -509,8 +723,8 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware private EntityId getProfileId(TenantId tenantId, EntityId entityId) { return switch (entityId.getEntityType()) { - case ASSET -> assetProfileCache.get(tenantId, (AssetId) entityId).getId(); - case DEVICE -> deviceProfileCache.get(tenantId, (DeviceId) entityId).getId(); + case ASSET -> Optional.ofNullable(assetProfileCache.get(tenantId, (AssetId) entityId)).map(AssetProfile::getId).orElse(null); + case DEVICE -> Optional.ofNullable(deviceProfileCache.get(tenantId, (DeviceId) entityId)).map(DeviceProfile::getId).orElse(null); default -> null; }; } @@ -524,13 +738,13 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware private void addLinks(CalculatedField newCf) { var newLinks = newCf.getConfiguration().buildCalculatedFieldLinks(tenantId, newCf.getEntityId(), newCf.getId()); - newLinks.forEach(link -> entityIdCalculatedFieldLinks.computeIfAbsent(link.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(link)); + newLinks.forEach(link -> entityIdCalculatedFieldLinks.computeIfAbsent(link.entityId(), id -> new CopyOnWriteArrayList<>()).add(link)); } private void deleteLinks(CalculatedFieldCtx cfCtx) { var oldCf = cfCtx.getCalculatedField(); var oldLinks = oldCf.getConfiguration().buildCalculatedFieldLinks(tenantId, oldCf.getEntityId(), oldCf.getId()); - oldLinks.forEach(link -> entityIdCalculatedFieldLinks.computeIfAbsent(link.getEntityId(), id -> new CopyOnWriteArrayList<>()).remove(link)); + oldLinks.forEach(link -> entityIdCalculatedFieldLinks.computeIfAbsent(link.entityId(), id -> new CopyOnWriteArrayList<>()).remove(link)); } public void onPartitionChange(CalculatedFieldPartitionChangeMsg msg) { @@ -543,56 +757,74 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware log.trace("Processing calculated field record: {}", cf); try { initCalculatedField(cf); + initCalculatedFieldLinks(cf); } catch (CalculatedFieldException e) { log.error("Failed to process calculated field record: {}", cf, e); } }); - PageDataIterable cfls = new PageDataIterable<>(pageLink -> cfDaoService.findAllCalculatedFieldLinksByTenantId(tenantId, pageLink), cfSettings.getInitTenantFetchPackSize()); - cfls.forEach(link -> { - log.trace("Processing calculated field link record: {}", link); - initCalculatedFieldLink(link); - }); } private void initCalculatedField(CalculatedField cf) throws CalculatedFieldException { - var cfCtx = new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService(), systemContext.getRelationService()); + var cfCtx = new CalculatedFieldCtx(cf, systemContext); try { cfCtx.init(); } catch (Exception e) { throw CalculatedFieldException.builder().ctx(cfCtx).eventEntity(cf.getEntityId()).cause(e).errorMessage("Failed to initialize CF context").build(); + } finally { + calculatedFields.put(cf.getId(), cfCtx); + // We use copy on write lists to safely pass the reference to another actor for the iteration. + // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) + entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx); } - calculatedFields.put(cf.getId(), cfCtx); - // We use copy on write lists to safely pass the reference to another actor for the iteration. - // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) - entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx); - scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(cfCtx); } - private void initCalculatedFieldLink(CalculatedFieldLink link) { - // We use copy on write lists to safely pass the reference to another actor for the iteration. - // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) - entityIdCalculatedFieldLinks.computeIfAbsent(link.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(link); + private void initCalculatedFieldLinks(CalculatedField cf) { + List links = cf.getConfiguration().buildCalculatedFieldLinks(cf.getTenantId(), cf.getEntityId(), cf.getId()); + for (CalculatedFieldLink link : links) { + // We use copy on write lists to safely pass the reference to another actor for the iteration. + // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) + entityIdCalculatedFieldLinks.computeIfAbsent(link.entityId(), id -> new CopyOnWriteArrayList<>()).add(link); + } } - private void initEntityProfileCache() { + private void initEntitiesCache() { PageDataIterable deviceIdInfos = new PageDataIterable<>(pageLink -> deviceService.findProfileEntityIdInfosByTenantId(tenantId, pageLink), cfSettings.getInitTenantFetchPackSize()); for (ProfileEntityIdInfo idInfo : deviceIdInfos) { log.trace("Processing device record: {}", idInfo); try { entityProfileCache.add(idInfo.getProfileId(), idInfo.getEntityId()); + ownerEntities.computeIfAbsent(idInfo.getOwnerId(), __ -> new HashSet<>()).add(idInfo.getEntityId()); } catch (Exception e) { log.error("Failed to process device record: {}", idInfo, e); } } + PageDataIterable assetIdInfos = new PageDataIterable<>(pageLink -> assetService.findProfileEntityIdInfosByTenantId(tenantId, pageLink), cfSettings.getInitTenantFetchPackSize()); for (ProfileEntityIdInfo idInfo : assetIdInfos) { log.trace("Processing asset record: {}", idInfo); try { entityProfileCache.add(idInfo.getProfileId(), idInfo.getEntityId()); + ownerEntities.computeIfAbsent(idInfo.getOwnerId(), __ -> new HashSet<>()).add(idInfo.getEntityId()); } catch (Exception e) { log.error("Failed to process asset record: {}", idInfo, e); } } + + PageDataIterable customers = new PageDataIterable<>(pageLink -> customerService.findCustomersByTenantId(tenantId, pageLink), cfSettings.getInitTenantFetchPackSize()); + for (Customer customer : customers) { + log.trace("Processing customer record: {}", customer); + try { + ownerEntities.computeIfAbsent(customer.getTenantId(), __ -> new HashSet<>()).add(customer.getId()); + } catch (Exception e) { + log.error("Failed to process customer record: {}", customer, e); + } + } + } + + private void updateEntityOwner(EntityId entityId) { + ownerEntities.values().forEach(entities -> entities.remove(entityId)); + EntityId owner = ownerService.getOwner(tenantId, entityId); + ownerEntities.computeIfAbsent(owner, ownerId -> new HashSet<>()).add(entityId); } private void applyToTargetCfEntityActors(CalculatedFieldCtx ctx, diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldDynamicArgumentsRefreshMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldReevaluateMsg.java similarity index 78% rename from application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldDynamicArgumentsRefreshMsg.java rename to application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldReevaluateMsg.java index 301fe22dfb..b617736ee0 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldDynamicArgumentsRefreshMsg.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldReevaluateMsg.java @@ -16,20 +16,20 @@ package org.thingsboard.server.actors.calculatedField; import lombok.Data; -import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; @Data -public class CalculatedFieldDynamicArgumentsRefreshMsg implements ToCalculatedFieldSystemMsg { +public class CalculatedFieldReevaluateMsg implements ToCalculatedFieldSystemMsg { private final TenantId tenantId; - private final CalculatedFieldId cfId; + private final CalculatedFieldCtx ctx; @Override public MsgType getMsgType() { - return MsgType.CF_DYNAMIC_ARGUMENTS_REFRESH_MSG; + return MsgType.CF_REEVALUATE_MSG; } } diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldRelationActionMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldRelationActionMsg.java new file mode 100644 index 0000000000..4d8e1cf561 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldRelationActionMsg.java @@ -0,0 +1,52 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.calculatedField; + +import lombok.Data; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; + +@Data +public class CalculatedFieldRelationActionMsg implements ToCalculatedFieldSystemMsg { + + private final TenantId tenantId; + private final EntityId relatedEntityId; + private final ActionType action; + private final CalculatedFieldCtx calculatedField; + private final TbCallback callback; + + public CalculatedFieldRelationActionMsg(TenantId tenantId, + EntityId relatedEntityId, ActionType action, + CalculatedFieldCtx calculatedField, + TbCallback callback) { + this.tenantId = tenantId; + this.relatedEntityId = relatedEntityId; + this.action = action; + this.calculatedField = calculatedField; + this.callback = callback; + } + + @Override + public MsgType getMsgType() { + return MsgType.CF_RELATION_ACTION_MSG; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldStateRestoreMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldStateRestoreMsg.java index 19be7c02fa..d1c2f11aeb 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldStateRestoreMsg.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldStateRestoreMsg.java @@ -19,7 +19,9 @@ import lombok.Data; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; @Data @@ -27,6 +29,8 @@ public class CalculatedFieldStateRestoreMsg implements ToCalculatedFieldSystemMs private final CalculatedFieldEntityCtxId id; private final CalculatedFieldState state; + private final TopicPartitionInfo partition; + private CalculatedFieldCtx ctx; @Override public MsgType getMsgType() { diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldTelemetryMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldTelemetryMsg.java index 68cd149cdf..a174cff268 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldTelemetryMsg.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldTelemetryMsg.java @@ -31,9 +31,9 @@ public class CalculatedFieldTelemetryMsg implements ToCalculatedFieldSystemMsg { private final CalculatedFieldTelemetryMsgProto proto; private final TbCallback callback; - @Override public MsgType getMsgType() { return MsgType.CF_TELEMETRY_MSG; } + } diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityInitCalculatedFieldMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityInitCalculatedFieldMsg.java index 1e8990ff8d..1e0025988d 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityInitCalculatedFieldMsg.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityInitCalculatedFieldMsg.java @@ -16,26 +16,29 @@ package org.thingsboard.server.actors.calculatedField; import lombok.Data; -import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; import org.thingsboard.server.common.msg.queue.TbCallback; -import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; -import java.util.List; - @Data public class EntityInitCalculatedFieldMsg implements ToCalculatedFieldSystemMsg { private final TenantId tenantId; private final CalculatedFieldCtx ctx; + private final StateAction stateAction; private final TbCallback callback; - private final boolean forceReinit; @Override public MsgType getMsgType() { return MsgType.CF_ENTITY_INIT_CF_MSG; } + + public enum StateAction { + INIT, + REINIT, + RECREATE, + REPROCESS + } } diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index c143214e2b..b24b803950 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -115,14 +115,9 @@ import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.stream.Collectors; - -/** - * @author Andrew Shvayka - */ @Slf4j public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { - static final String SESSION_TIMEOUT_MESSAGE = "session timeout!"; final TenantId tenantId; final DeviceId deviceId; final LinkedHashMapRemoveEldest sessions; @@ -178,7 +173,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso private EdgeId findRelatedEdgeId() { List result = systemContext.getRelationService().findByToAndType(tenantId, deviceId, EntityRelation.CONTAINS_TYPE, RelationTypeGroup.EDGE); - if (result != null && result.size() > 0) { + if (result != null && !result.isEmpty()) { EntityRelation relationToEdge = result.get(0); if (relationToEdge.getFrom() != null && relationToEdge.getFrom().getId() != null) { log.trace("[{}][{}] found edge [{}] for device", tenantId, deviceId, relationToEdge.getFrom().getId()); @@ -501,7 +496,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso UUID sessionId = getSessionId(sessionInfo); DeviceId deviceId = new DeviceId(new UUID(msg.getDeviceIdMSB(), msg.getDeviceIdLSB())); ListenableFuture registrationFuture = systemContext.getClaimDevicesService() - .registerClaimingInfo(tenantId, deviceId, msg.getSecretKey(), msg.getDurationMs()); + .registerClaimingInfo(tenantId, deviceId, msg.getSecretKey(), msg.getDurationMs()); Futures.addCallback(registrationFuture, new FutureCallback<>() { @Override public void onSuccess(Void result) { @@ -723,7 +718,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso toDeviceRpcPendingMap.remove(requestId); status = RpcStatus.FAILED; response = JacksonUtil.newObjectNode().put("error", "There was a Timeout and all retry " + - "attempts have been exhausted. Retry attempts set: " + maxRpcRetries); + "attempts have been exhausted. Retry attempts set: " + maxRpcRetries); } } else { md.setRetries(md.getRetries() + 1); diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index 6374e4016d..03830c1c4c 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -51,6 +51,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.HasRuleEngineProfile; +import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.alarm.Alarm; @@ -60,6 +61,7 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.id.TenantId; @@ -110,6 +112,7 @@ import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; @@ -770,6 +773,11 @@ public class DefaultTbContext implements TbContext { return mainCtx.getResourceService(); } + @Override + public TbResourceDataCache getTbResourceDataCache() { + return mainCtx.getResourceDataCache(); + } + @Override public OtaPackageService getOtaPackageService() { return mainCtx.getOtaPackageService(); @@ -1054,11 +1062,20 @@ public class DefaultTbContext implements TbContext { @Override public void checkTenantEntity(EntityId entityId) throws TbNodeException { - if (!this.getTenantId().equals(TenantIdLoader.findTenantId(this, entityId))) { + TenantId actualTenantId = TenantIdLoader.findTenantId(this, entityId); + if (!getTenantId().equals(actualTenantId)) { throw new TbNodeException("Entity with id: '" + entityId + "' specified in the configuration doesn't belong to the current tenant.", true); } } + @Override + public & HasTenantId, I extends EntityId> void checkTenantOrSystemEntity(E entity) throws TbNodeException { + TenantId actualTenantId = entity.getTenantId(); + if (!getTenantId().equals(actualTenantId) && !actualTenantId.isSysTenantId()) { + throw new TbNodeException("Entity with id: '" + entity.getId() + "' specified in the configuration doesn't belong to the current or system tenant.", true); + } + } + private static String getFailureMessage(Throwable th) { String failureMessage; if (th != null) { diff --git a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java index 91d25a633b..35a7f01b2e 100644 --- a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java @@ -50,6 +50,7 @@ import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; import org.thingsboard.server.common.msg.aware.DeviceAwareMsg; import org.thingsboard.server.common.msg.aware.RuleChainAwareMsg; +import org.thingsboard.server.common.msg.aware.TenantAwareMsg; import org.thingsboard.server.common.msg.cf.CalculatedFieldCacheInitMsg; import org.thingsboard.server.common.msg.cf.CalculatedFieldEntityLifecycleMsg; import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; @@ -155,6 +156,9 @@ public class TenantActor extends RuleChainManagerActor { case COMPONENT_LIFE_CYCLE_MSG: onComponentLifecycleMsg((ComponentLifecycleMsg) msg); break; + case CF_ENTITY_ACTION_EVENT_MSG: + forwardToCfActor((TenantAwareMsg) msg, true); + break; case QUEUE_TO_RULE_ENGINE_MSG: onQueueToRuleEngineMsg((QueueToRuleEngineMsg) msg); break; @@ -182,11 +186,12 @@ public class TenantActor extends RuleChainManagerActor { case CF_CACHE_INIT_MSG: case CF_STATE_RESTORE_MSG: case CF_PARTITIONS_CHANGE_MSG: - onToCalculatedFieldSystemActorMsg((ToCalculatedFieldSystemMsg) msg, true); + case CF_STATE_PARTITION_RESTORE_MSG: + forwardToCfActor((ToCalculatedFieldSystemMsg) msg, true); break; case CF_TELEMETRY_MSG: case CF_LINKED_TELEMETRY_MSG: - onToCalculatedFieldSystemActorMsg((ToCalculatedFieldSystemMsg) msg, false); + forwardToCfActor((ToCalculatedFieldSystemMsg) msg, false); break; default: return false; @@ -194,7 +199,7 @@ public class TenantActor extends RuleChainManagerActor { return true; } - private void onToCalculatedFieldSystemActorMsg(ToCalculatedFieldSystemMsg msg, boolean priority) { + private void forwardToCfActor(TenantAwareMsg msg, boolean priority) { if (cfActor == null) { if (msg instanceof CalculatedFieldStateRestoreMsg) { log.warn("[{}] CF Actor is not initialized. ToCalculatedFieldSystemMsg: [{}]", tenantId, msg); @@ -345,7 +350,7 @@ public class TenantActor extends RuleChainManagerActor { } } if (cfActor != null) { - if (msg.getEntityId().getEntityType().isOneOf(EntityType.CALCULATED_FIELD, EntityType.DEVICE, EntityType.ASSET)) { + if (msg.getEntityId().getEntityType().isOneOf(EntityType.CALCULATED_FIELD, EntityType.DEVICE, EntityType.ASSET, EntityType.CUSTOMER)) { cfActor.tellWithHighPriority(new CalculatedFieldEntityLifecycleMsg(tenantId, msg)); } } @@ -390,6 +395,7 @@ public class TenantActor extends RuleChainManagerActor { public TbActor createActor() { return new TenantActor(context, tenantId); } + } } diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java index 2a4cce143a..d4223d30f1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java @@ -34,6 +34,9 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.NameConflictPolicy; +import org.thingsboard.server.common.data.NameConflictStrategy; +import org.thingsboard.server.common.data.UniquifyStrategy; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; import org.thingsboard.server.common.data.asset.AssetSearchQuery; @@ -76,6 +79,8 @@ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -83,6 +88,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC; import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; import static org.thingsboard.server.controller.EdgeController.EDGE_ID; @@ -137,10 +143,16 @@ public class AssetController extends BaseController { @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/asset", method = RequestMethod.POST) @ResponseBody - public Asset saveAsset(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the asset.") @RequestBody Asset asset) throws Exception { + public Asset saveAsset(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the asset.") @RequestBody Asset asset, + @Parameter(description = NAME_CONFLICT_POLICY_DESC) + @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy, + @Parameter(description = UNIQUIFY_SEPARATOR_DESC) + @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator, + @Parameter(description = UNIQUIFY_STRATEGY_DESC) + @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception { asset.setTenantId(getTenantId()); checkEntity(asset.getId(), asset, Resource.ASSET); - return tbAssetService.save(asset, getCurrentUser()); + return tbAssetService.save(asset, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser()); } @ApiOperation(value = "Delete asset (deleteAsset)", 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 0e21444431..b15b650c33 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -39,6 +39,7 @@ import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.limit.LimitedApi; +import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.security.event.UserCredentialsInvalidationEvent; import org.thingsboard.server.common.data.security.event.UserSessionInvalidationEvent; @@ -48,7 +49,9 @@ import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.dao.settings.SecuritySettingsService; import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; +import org.thingsboard.server.service.security.auth.rest.RestAwareAuthenticationSuccessHandler; import org.thingsboard.server.service.security.model.ActivateUserRequest; import org.thingsboard.server.service.security.model.ChangePasswordRequest; import org.thingsboard.server.service.security.model.ResetPasswordEmailRequest; @@ -74,7 +77,8 @@ public class AuthController extends BaseController { private final SecuritySettingsService securitySettingsService; private final RateLimitService rateLimitService; private final ApplicationEventPublisher eventPublisher; - + private final TwoFactorAuthService twoFactorAuthService; + private final RestAwareAuthenticationSuccessHandler authenticationSuccessHandler; @ApiOperation(value = "Get current User (getUser)", notes = "Get the information about the User which credentials are used to perform this REST API call.") @@ -221,7 +225,13 @@ public class AuthController extends BaseController { } } - var tokenPair = tokenFactory.createTokenPair(securityUser); + JwtPair tokenPair; + if (twoFactorAuthService.isEnforceTwoFaEnabled(securityUser.getTenantId(), user)) { + tokenPair = authenticationSuccessHandler.createMfaTokenPair(securityUser, Authority.MFA_CONFIGURATION_TOKEN); + } else { + tokenPair = tokenFactory.createTokenPair(securityUser); + } + systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(request), ActionType.LOGIN, null); return tokenPair; } diff --git a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java index c5b077c128..9a24ab91fc 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java @@ -44,6 +44,7 @@ import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EventInfo; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.event.EventType; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -62,10 +63,10 @@ import org.thingsboard.server.service.security.permission.Operation; import java.util.ArrayList; import java.util.Collections; -import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.TimeUnit; import static org.thingsboard.server.controller.ControllerConstants.CF_TEXT_SEARCH_DESCRIPTION; @@ -159,19 +160,27 @@ public class CalculatedFieldController extends BaseController { ) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") @GetMapping(value = "/{entityType}/{entityId}/calculatedFields", params = {"pageSize", "page"}) - public PageData getCalculatedFieldsByEntityId( - @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, - @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, - @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, - @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) @RequestParam int page, - @Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) @RequestParam(required = false) String sortProperty, - @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(required = false) String sortOrder) throws ThingsboardException { + public PageData getCalculatedFieldsByEntityId(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) + @PathVariable("entityType") String entityType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("entityId") String entityIdStr, + @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = "Calculated field type. If not specified, all types will be returned.") + @RequestParam(required = false) CalculatedFieldType type, + @Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) + @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); checkParameter("entityId", entityIdStr); EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, entityIdStr); checkEntityId(entityId, Operation.READ_CALCULATED_FIELD); - return checkNotNull(tbCalculatedFieldService.findAllByTenantIdAndEntityId(entityId, getCurrentUser(), pageLink)); + return checkNotNull(tbCalculatedFieldService.findByTenantIdAndEntityId(getTenantId(), entityId, type, pageLink)); } @ApiOperation(value = "Delete Calculated Field (deleteCalculatedField)", @@ -278,7 +287,7 @@ public class CalculatedFieldController extends BaseController { } private void checkReferencedEntities(CalculatedFieldConfiguration calculatedFieldConfig) throws ThingsboardException { - List referencedEntityIds = calculatedFieldConfig.getReferencedEntities(); + Set referencedEntityIds = calculatedFieldConfig.getReferencedEntities(); for (EntityId referencedEntityId : referencedEntityIds) { EntityType entityType = referencedEntityId.getEntityType(); switch (entityType) { diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index a87864726b..7dfa616ab5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -1630,11 +1630,13 @@ public class ControllerConstants { protected static final String ENTITY_VIEW_INFO_DESCRIPTION = "Entity Views Info extends the Entity View with customer title and 'is public' flag. " + ENTITY_VIEW_DESCRIPTION; protected static final String ATTRIBUTES_SCOPE_DESCRIPTION = "A string value representing the attributes scope. For example, 'SERVER_SCOPE'."; - protected static final String ATTRIBUTES_KEYS_DESCRIPTION = "A string value representing the comma-separated list of attributes keys. For example, 'active,inactivityAlarmTime'."; + protected static final String ATTRIBUTES_KEYS_DESCRIPTION = "A string value representing the comma-separated list of attributes keys. For example, 'active,inactivityAlarmTime'. " + + "If attribute keys contain comma, duplicate 'key' parameter for each key, for example '?key=my,key&key=my,second,key"; protected static final String ATTRIBUTES_JSON_REQUEST_DESCRIPTION = "A string value representing the json object. For example, '{\"key\":\"value\"}'. See API call description for more details."; protected static final String TELEMETRY_KEYS_BASE_DESCRIPTION = "A string value representing the comma-separated list of telemetry keys."; - protected static final String TELEMETRY_KEYS_DESCRIPTION = TELEMETRY_KEYS_BASE_DESCRIPTION + " If keys are not selected, the result will return all latest time series. For example, 'temperature,humidity'."; + protected static final String TELEMETRY_KEYS_DESCRIPTION = TELEMETRY_KEYS_BASE_DESCRIPTION + " If keys are not selected, the result will return all latest time series. For example, 'temperature,humidity'. " + + "If telemetry keys contain comma, duplicate 'key' parameter for each key, for example '?key=my,key&key=my,second,key"; protected static final String TELEMETRY_SCOPE_DESCRIPTION = "Value is deprecated, reserved for backward compatibility and not used in the API call implementation. Specify any scope for compatibility"; protected static final String TELEMETRY_JSON_REQUEST_DESCRIPTION = "A JSON with the telemetry values. See API call description for more details."; @@ -1744,4 +1746,18 @@ public class ControllerConstants { MARKDOWN_CODE_BLOCK_END ; protected static final String SECURITY_WRITE_CHECK = " Security check is performed to verify that the user has 'WRITE' permission for the entity (entities)."; + + public static final String NAME_CONFLICT_POLICY_DESC = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + + " If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + + " UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs."; + + public static final String UNIQUIFY_SEPARATOR_DESC = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for entity name 'test-name', " + + "created entity will have name like 'test-name-7fsh4f'."; + + public static final String UNIQUIFY_STRATEGY_DESC = "Optional value of uniquify strategy used by UNIQUIFY policy. Possible values: RANDOM or INCREMENTAL. " + + "By default, RANDOM strategy is used, which means random alphanumeric string will be added as a suffix to entity name. " + + "INCREMENTAL implies the first possible number starting from 1 will be added as a name suffix. " + + "For example, strategy is UNIQUIFY, uniquify strategy is INCREMENTAL; if a name conflict occurs for entity name 'test-name', " + + "created entity will have name like 'test-name-1."; } 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 03ac9c60fa..fca7ce3e86 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java @@ -32,6 +32,9 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.NameConflictStrategy; +import org.thingsboard.server.common.data.NameConflictPolicy; +import org.thingsboard.server.common.data.UniquifyStrategy; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; @@ -47,6 +50,8 @@ import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID; import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_TEXT_SEARCH_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.HOME_DASHBOARD; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -54,6 +59,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC; import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; @RestController @@ -128,10 +134,16 @@ public class CustomerController extends BaseController { @PreAuthorize("hasAuthority('TENANT_ADMIN')") @RequestMapping(value = "/customer", method = RequestMethod.POST) @ResponseBody - public Customer saveCustomer(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the customer.") @RequestBody Customer customer) throws Exception { + public Customer saveCustomer(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the customer.") @RequestBody Customer customer, + @Parameter(description = NAME_CONFLICT_POLICY_DESC) + @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy, + @Parameter(description = UNIQUIFY_SEPARATOR_DESC) + @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator, + @Parameter(description = UNIQUIFY_STRATEGY_DESC) + @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception { customer.setTenantId(getTenantId()); checkEntity(customer.getId(), customer, Resource.CUSTOMER); - return tbCustomerService.save(customer, getCurrentUser()); + return tbCustomerService.save(customer, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser()); } @ApiOperation(value = "Delete Customer (deleteCustomer)", diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java index 61864dbf63..8bbf4ae2f6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java @@ -46,8 +46,11 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceInfo; import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.NameConflictPolicy; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.UniquifyStrategy; import org.thingsboard.server.common.data.device.DeviceSearchQuery; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; @@ -108,6 +111,8 @@ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -117,6 +122,7 @@ import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHO import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID; import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC; import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; import static org.thingsboard.server.controller.EdgeController.EDGE_ID; @@ -177,14 +183,21 @@ public class DeviceController extends BaseController { @ResponseBody public Device saveDevice(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the device.") @RequestBody Device device, @Parameter(description = "Optional value of the device credentials to be used during device creation. " + - "If omitted, access token will be auto-generated.") @RequestParam(name = "accessToken", required = false) String accessToken) throws Exception { + "If omitted, access token will be auto-generated.") + @RequestParam(name = "accessToken", required = false) String accessToken, + @Parameter(description = NAME_CONFLICT_POLICY_DESC) + @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy, + @Parameter(description = UNIQUIFY_SEPARATOR_DESC) + @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator, + @Parameter(description = UNIQUIFY_STRATEGY_DESC) + @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception { device.setTenantId(getCurrentUser().getTenantId()); if (device.getId() != null) { checkDeviceId(device.getId(), Operation.WRITE); } else { checkEntity(null, device, Resource.DEVICE); } - return tbDeviceService.save(device, accessToken, getCurrentUser()); + return tbDeviceService.save(device, accessToken, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser()); } @ApiOperation(value = "Create Device (saveDevice) with credentials ", @@ -209,12 +222,18 @@ public class DeviceController extends BaseController { @RequestMapping(value = "/device-with-credentials", method = RequestMethod.POST) @ResponseBody public Device saveDeviceWithCredentials(@Parameter(description = "The JSON object with device and credentials. See method description above for example.") - @Valid @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials) throws ThingsboardException { + @Valid @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials, + @Parameter(description = NAME_CONFLICT_POLICY_DESC) + @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy, + @Parameter(description = UNIQUIFY_SEPARATOR_DESC) + @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator, + @Parameter(description = UNIQUIFY_STRATEGY_DESC) + @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws ThingsboardException { Device device = deviceAndCredentials.getDevice(); DeviceCredentials credentials = deviceAndCredentials.getCredentials(); device.setTenantId(getCurrentUser().getTenantId()); checkEntity(device.getId(), device, Resource.DEVICE); - return tbDeviceService.saveDeviceWithCredentials(device, credentials, getCurrentUser()); + return tbDeviceService.saveDeviceWithCredentials(device, credentials, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser()); } @ApiOperation(value = "Delete device (deleteDevice)", 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 b1b6b6b1e3..67fc02ab29 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -34,6 +34,9 @@ import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.NameConflictPolicy; +import org.thingsboard.server.common.data.NameConflictStrategy; +import org.thingsboard.server.common.data.UniquifyStrategy; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -69,6 +72,8 @@ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_TEXT_SEARCH_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_TYPE; import static org.thingsboard.server.controller.ControllerConstants.MODEL_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -76,6 +81,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC; import static org.thingsboard.server.controller.EdgeController.EDGE_ID; /** @@ -128,7 +134,13 @@ public class EntityViewController extends BaseController { @ResponseBody public EntityView saveEntityView( @Parameter(description = "A JSON object representing the entity view.") - @RequestBody EntityView entityView) throws Exception { + @RequestBody EntityView entityView, + @Parameter(description = NAME_CONFLICT_POLICY_DESC) + @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy, + @Parameter(description = UNIQUIFY_SEPARATOR_DESC) + @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator, + @Parameter(description = UNIQUIFY_STRATEGY_DESC) + @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception { entityView.setTenantId(getCurrentUser().getTenantId()); EntityView existingEntityView = null; if (entityView.getId() == null) { @@ -137,7 +149,7 @@ public class EntityViewController extends BaseController { } else { existingEntityView = checkEntityViewId(entityView.getId(), Operation.WRITE); } - return tbEntityViewService.save(entityView, existingEntityView, getCurrentUser()); + return tbEntityViewService.save(entityView, existingEntityView, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser()); } @ApiOperation(value = "Delete entity view (deleteEntityView)", diff --git a/application/src/main/java/org/thingsboard/server/controller/ImageController.java b/application/src/main/java/org/thingsboard/server/controller/ImageController.java index f9ec7fd844..9288484f86 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ImageController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ImageController.java @@ -300,6 +300,7 @@ public class ImageController extends BaseController { tbImageService.putETag(cacheKey, descriptor.getEtag()); var result = ResponseEntity.ok() .header("Content-Type", descriptor.getMediaType()) + .header("Content-Security-Policy", "default-src 'none'") .eTag(descriptor.getEtag()); if (!cacheKey.isPublic()) { result diff --git a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java index f0ec727896..7e7cd84a12 100644 --- a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java +++ b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java @@ -27,6 +27,8 @@ import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.NameConflictPolicy; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.LwM2MServerSecurityConfigDefault; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -37,6 +39,7 @@ import org.thingsboard.server.service.lwm2m.LwM2MService; import java.util.Map; +import static org.thingsboard.server.common.data.NameConflictStrategy.DEFAULT; import static org.thingsboard.server.controller.ControllerConstants.IS_BOOTSTRAP_SERVER_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; @@ -73,6 +76,6 @@ public class Lwm2mController extends BaseController { public Device saveDeviceWithCredentials(@RequestBody Map, Object> deviceWithDeviceCredentials) throws ThingsboardException { Device device = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(Device.class), Device.class)); DeviceCredentials credentials = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(DeviceCredentials.class), DeviceCredentials.class)); - return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials)); + return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials), DEFAULT.policy(), DEFAULT.separator(), DEFAULT.uniquifyStrategy()); } } 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 b9968aefa9..82807d0762 100644 --- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java +++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java @@ -164,6 +164,7 @@ public class SystemInfoController extends BaseController { systemParams.setMaxDataPointsPerRollingArg(tenantProfileConfiguration.getMaxDataPointsPerRollingArg()); systemParams.setMinAllowedScheduledUpdateIntervalInSecForCF(tenantProfileConfiguration.getMinAllowedScheduledUpdateIntervalInSecForCF()); systemParams.setMaxRelationLevelPerCfArgument(tenantProfileConfiguration.getMaxRelationLevelPerCfArgument()); + systemParams.setMinAllowedDeduplicationIntervalInSecForCF(tenantProfileConfiguration.getMinAllowedDeduplicationIntervalInSecForCF()); systemParams.setTrendzSettings(trendzSettingsService.findTrendzSettings(currentUser.getTenantId())); } systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID)) diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java index b23603f6a2..54d2494679 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java @@ -55,14 +55,17 @@ import org.thingsboard.server.common.data.util.ThrowingSupplier; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.resource.TbResourceService; +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 java.util.ArrayList; import java.util.Collections; import java.util.EnumSet; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.UUID; import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER; import static org.thingsboard.server.controller.ControllerConstants.LWM2M_OBJECT_DESCRIPTION; @@ -263,6 +266,20 @@ public class TbResourceController extends BaseController { } } + @ApiOperation(value = "Get Resource Infos by ids (getSystemOrTenantResourcesByIds)") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/resource", params = {"resourceIds"}) + public List getSystemOrTenantResourcesByIds( + @Parameter(description = "A list of resource ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string"))) + @RequestParam("resourceIds") Set resourceUuids) throws ThingsboardException { + SecurityUser user = getCurrentUser(); + List resourceIds = new ArrayList<>(); + for (UUID resourceId : resourceUuids) { + resourceIds.add(new TbResourceId(resourceId)); + } + return resourceService.findSystemOrTenantResourcesByIds(user.getTenantId(), resourceIds); + } + @ApiOperation(value = "Get All Resource Infos (getAllResources)", notes = "Returns a page of Resource Info objects owned by tenant. " + PAGE_DATA_PARAMETERS + RESOURCE_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) diff --git a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java index c98ae0dcb8..02e3debda8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -36,6 +36,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -208,10 +209,12 @@ public class TelemetryController extends BaseController { public DeferredResult getAttributes( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, - @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr) throws ThingsboardException { + @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, + @RequestParam MultiValueMap params) throws ThingsboardException { + List keys = getKeys(keysStr, params); SecurityUser user = getCurrentUser(); return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_ATTRIBUTES, entityType, entityIdStr, - (result, tenantId, entityId) -> getAttributeValuesCallback(result, user, entityId, null, keysStr)); + (result, tenantId, entityId) -> getAttributeValuesCallback(result, user, entityId, null, keys)); } @@ -231,10 +234,12 @@ public class TelemetryController extends BaseController { @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, - @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr) throws ThingsboardException { + @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, + @RequestParam MultiValueMap params) throws ThingsboardException { + List keys = getKeys(keysStr, params); SecurityUser user = getCurrentUser(); return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_ATTRIBUTES, entityType, entityIdStr, - (result, tenantId, entityId) -> getAttributeValuesCallback(result, user, entityId, scope, keysStr)); + (result, tenantId, entityId) -> getAttributeValuesCallback(result, user, entityId, scope, keys)); } @ApiOperation(value = "Get time series keys (getTimeseriesKeys)", @@ -270,10 +275,12 @@ public class TelemetryController extends BaseController { @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @Parameter(description = TELEMETRY_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, @Parameter(description = STRICT_DATA_TYPES_DESCRIPTION) - @RequestParam(name = "useStrictDataTypes", required = false, defaultValue = "false") Boolean useStrictDataTypes) throws ThingsboardException { + @RequestParam(name = "useStrictDataTypes", required = false, defaultValue = "false") Boolean useStrictDataTypes, + @RequestParam MultiValueMap params) throws ThingsboardException { + List keys = getKeys(keysStr, params); SecurityUser user = getCurrentUser(); return accessValidator.validateEntityAndCallback(getCurrentUser(), Operation.READ_TELEMETRY, entityType, entityIdStr, - (result, tenantId, entityId) -> getLatestTimeseriesValuesCallback(result, user, entityId, keysStr, useStrictDataTypes)); + (result, tenantId, entityId) -> getLatestTimeseriesValuesCallback(result, user, entityId, keys, useStrictDataTypes)); } @ApiOperation(value = "Get time series data (getTimeseries)", @@ -291,7 +298,7 @@ public class TelemetryController extends BaseController { public DeferredResult getTimeseries( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, - @Parameter(description = TELEMETRY_KEYS_BASE_DESCRIPTION, required = true) @RequestParam(name = "keys") String keys, + @Parameter(description = TELEMETRY_KEYS_BASE_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, @Parameter(description = "A long value representing the start timestamp of the time range in milliseconds, UTC.") @RequestParam(name = "startTs") Long startTs, @Parameter(description = "A long value representing the end timestamp of the time range in milliseconds, UTC.") @@ -312,9 +319,11 @@ public class TelemetryController extends BaseController { @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(name = "orderBy", defaultValue = "DESC") String orderBy, @Parameter(description = STRICT_DATA_TYPES_DESCRIPTION) - @RequestParam(name = "useStrictDataTypes", required = false, defaultValue = "false") Boolean useStrictDataTypes) throws ThingsboardException { + @RequestParam(name = "useStrictDataTypes", required = false, defaultValue = "false") Boolean useStrictDataTypes, + @RequestParam MultiValueMap params) throws ThingsboardException { + List keys = getKeys(keysStr, params); DeferredResult response = new DeferredResult<>(); - Futures.addCallback(tbTelemetryService.getTimeseries(EntityIdFactory.getByTypeAndId(entityType, entityIdStr), toKeysList(keys), startTs, endTs, + Futures.addCallback(tbTelemetryService.getTimeseries(EntityIdFactory.getByTypeAndId(entityType, entityIdStr), keys, startTs, endTs, intervalType, interval, timeZone, limit, Aggregation.valueOf(aggStr), orderBy, useStrictDataTypes, getCurrentUser()), getTsKvListCallback(response, useStrictDataTypes), MoreExecutors.directExecutor()); return response; @@ -466,7 +475,7 @@ public class TelemetryController extends BaseController { public DeferredResult deleteEntityTimeseries( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, - @Parameter(description = TELEMETRY_KEYS_DESCRIPTION, required = true) @RequestParam(name = "keys") String keysStr, + @Parameter(description = TELEMETRY_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, @Parameter(description = "A boolean value to specify if should be deleted all data for selected keys or only data that are in the selected time range.") @RequestParam(name = "deleteAllDataForKeys", defaultValue = "false") boolean deleteAllDataForKeys, @Parameter(description = "A long value representing the start timestamp of removal time range in milliseconds.") @@ -476,16 +485,17 @@ public class TelemetryController extends BaseController { @Parameter(description = "If the parameter is set to true, the latest telemetry can be removed, otherwise, in case that parameter is set to false the latest value will not removed.") @RequestParam(name = "deleteLatest", required = false, defaultValue = "true") boolean deleteLatest, @Parameter(description = "If the parameter is set to true, the latest telemetry will be rewritten in case that current latest value was removed, otherwise, in case that parameter is set to false the new latest value will not set.") - @RequestParam(name = "rewriteLatestIfDeleted", defaultValue = "false") boolean rewriteLatestIfDeleted) throws ThingsboardException { + @RequestParam(name = "rewriteLatestIfDeleted", defaultValue = "false") boolean rewriteLatestIfDeleted, + @RequestParam MultiValueMap params) throws ThingsboardException { + List keys = getKeys(keysStr, params); EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); - return deleteTimeseries(entityId, keysStr, deleteAllDataForKeys, startTs, endTs, rewriteLatestIfDeleted, deleteLatest); + return deleteTimeseries(entityId, keys, deleteAllDataForKeys, startTs, endTs, rewriteLatestIfDeleted, deleteLatest); } - private DeferredResult deleteTimeseries(EntityId entityIdStr, String keysStr, boolean deleteAllDataForKeys, + private DeferredResult deleteTimeseries(EntityId entityIdStr, List keys, boolean deleteAllDataForKeys, Long startTs, Long endTs, boolean rewriteLatestIfDeleted, boolean deleteLatest) throws ThingsboardException { - List keys = toKeysList(keysStr); if (keys.isEmpty()) { - return getImmediateDeferredResult("Empty keys: " + keysStr, HttpStatus.BAD_REQUEST); + return getImmediateDeferredResult("Empty keys: " + keys, HttpStatus.BAD_REQUEST); } SecurityUser user = getCurrentUser(); @@ -547,9 +557,11 @@ public class TelemetryController extends BaseController { public DeferredResult deleteDeviceAttributes( @Parameter(description = DEVICE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(DEVICE_ID) String deviceIdStr, @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, - @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION, required = true) @RequestParam(name = "keys") String keysStr) throws ThingsboardException { + @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, + @RequestParam MultiValueMap params) throws ThingsboardException { + List keys = getKeys(keysStr, params); EntityId entityId = EntityIdFactory.getByTypeAndUuid(EntityType.DEVICE, deviceIdStr); - return deleteAttributes(entityId, scope, keysStr); + return deleteAttributes(entityId, scope, keys); } @ApiOperation(value = "Delete entity attributes (deleteEntityAttributes)", @@ -570,15 +582,20 @@ public class TelemetryController extends BaseController { @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, required = true, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"})) @PathVariable("scope") AttributeScope scope, - @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION, required = true) @RequestParam(name = "keys") String keysStr) throws ThingsboardException { + @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, + @RequestParam MultiValueMap params) throws ThingsboardException { + List keys = getKeys(keysStr, params); EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); - return deleteAttributes(entityId, scope, keysStr); + return deleteAttributes(entityId, scope, keys); + } + + private List getKeys(String keysStr, MultiValueMap params) { + return params.get("key") != null ? params.get("key") : toKeysList(keysStr); } - private DeferredResult deleteAttributes(EntityId entityIdSrc, AttributeScope scope, String keysStr) throws ThingsboardException { - List keys = toKeysList(keysStr); + private DeferredResult deleteAttributes(EntityId entityIdSrc, AttributeScope scope, List keys) throws ThingsboardException { if (keys.isEmpty()) { - return getImmediateDeferredResult("Empty keys: " + keysStr, HttpStatus.BAD_REQUEST); + return getImmediateDeferredResult("Empty keys: " + keys, HttpStatus.BAD_REQUEST); } SecurityUser user = getCurrentUser(); @@ -709,30 +726,29 @@ public class TelemetryController extends BaseController { }); } - private void getLatestTimeseriesValuesCallback(@Nullable DeferredResult result, SecurityUser user, EntityId entityId, String keys, Boolean useStrictDataTypes) { + private void getLatestTimeseriesValuesCallback(@Nullable DeferredResult result, SecurityUser user, EntityId entityId, List keys, Boolean useStrictDataTypes) { ListenableFuture> future; - if (StringUtils.isEmpty(keys)) { + if (keys.isEmpty()) { future = tsService.findAllLatest(user.getTenantId(), entityId); } else { - future = tsService.findLatest(user.getTenantId(), entityId, toKeysList(keys)); + future = tsService.findLatest(user.getTenantId(), entityId, keys); } Futures.addCallback(future, getTsKvListCallback(result, useStrictDataTypes), MoreExecutors.directExecutor()); } - private void getAttributeValuesCallback(@Nullable DeferredResult result, SecurityUser user, EntityId entityId, AttributeScope scope, String keys) { - List keyList = toKeysList(keys); - FutureCallback> callback = getAttributeValuesToResponseCallback(result, user, scope, entityId, keyList); + private void getAttributeValuesCallback(@Nullable DeferredResult result, SecurityUser user, EntityId entityId, AttributeScope scope, List keys) { + FutureCallback> callback = getAttributeValuesToResponseCallback(result, user, scope, entityId, keys); if (scope != null) { - if (keyList != null && !keyList.isEmpty()) { - Futures.addCallback(attributesService.find(user.getTenantId(), entityId, scope, keyList), callback, MoreExecutors.directExecutor()); + if (keys != null && !keys.isEmpty()) { + Futures.addCallback(attributesService.find(user.getTenantId(), entityId, scope, keys), callback, MoreExecutors.directExecutor()); } else { Futures.addCallback(attributesService.findAll(user.getTenantId(), entityId, scope), callback, MoreExecutors.directExecutor()); } } else { List>> futures = new ArrayList<>(); for (AttributeScope tmpScope : AttributeScope.values()) { - if (keyList != null && !keyList.isEmpty()) { - futures.add(attributesService.find(user.getTenantId(), entityId, tmpScope, keyList)); + if (keys != null && !keys.isEmpty()) { + futures.add(attributesService.find(user.getTenantId(), entityId, tmpScope, keys)); } else { futures.add(attributesService.findAll(user.getTenantId(), entityId, tmpScope)); } diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantController.java b/application/src/main/java/org/thingsboard/server/controller/TenantController.java index 3ded7b7171..2f8be6589f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TenantController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TenantController.java @@ -115,7 +115,7 @@ public class TenantController extends BaseController { @ApiOperation(value = "Delete Tenant (deleteTenant)", notes = "Deletes the tenant, it's customers, rule chains, devices and all other related entities. Referencing non-existing tenant Id will cause an error." + SYSTEM_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAuthority('SYS_ADMIN')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @RequestMapping(value = "/tenant/{tenantId}", method = RequestMethod.DELETE) @ResponseStatus(value = HttpStatus.OK) public void deleteTenant(@Parameter(description = TENANT_ID_PARAM_DESCRIPTION) diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java index 6aae853636..eef086452f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java @@ -56,7 +56,6 @@ public class TwoFactorAuthConfigController extends BaseController { private final TwoFaConfigManager twoFaConfigManager; private final TwoFactorAuthService twoFactorAuthService; - @ApiOperation(value = "Get account 2FA settings (getAccountTwoFaSettings)", notes = "Get user's account 2FA configuration. Configuration contains configs for different 2FA providers." + NEW_LINE + "Example:\n" + @@ -67,13 +66,12 @@ public class TwoFactorAuthConfigController extends BaseController { " }\n}\n```" + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @GetMapping("/account/settings") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')") public AccountTwoFaSettings getAccountTwoFaSettings() throws ThingsboardException { SecurityUser user = getCurrentUser(); - return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user.getId()).orElse(null); + return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user).orElse(null); } - @ApiOperation(value = "Generate 2FA account config (generateTwoFaAccountConfig)", notes = "Generate new 2FA account config template for specified provider type. " + NEW_LINE + "For TOTP, this will return a corresponding account config template " + @@ -99,7 +97,7 @@ public class TwoFactorAuthConfigController extends BaseController { "Will throw an error (Bad Request) if the provider is not configured for usage. " + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PostMapping("/account/config/generate") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')") public TwoFaAccountConfig generateTwoFaAccountConfig(@Parameter(description = "2FA provider type to generate new account config for", schema = @Schema(defaultValue = "TOTP", requiredMode = Schema.RequiredMode.REQUIRED)) @RequestParam TwoFaProviderType providerType) throws Exception { SecurityUser user = getCurrentUser(); @@ -127,7 +125,7 @@ public class TwoFactorAuthConfigController extends BaseController { "or if the provider is not configured for usage. " + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PostMapping("/account/config/submit") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')") public void submitTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig) throws Exception { SecurityUser user = getCurrentUser(); twoFactorAuthService.prepareVerificationCode(user, accountConfig, false); @@ -139,11 +137,11 @@ public class TwoFactorAuthConfigController extends BaseController { "Will throw an error (Bad Request) if the provider is not configured for usage. " + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PostMapping("/account/config") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')") public AccountTwoFaSettings verifyAndSaveTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig, @RequestParam(required = false) String verificationCode) throws Exception { SecurityUser user = getCurrentUser(); - if (twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig.getProviderType()).isPresent()) { + if (twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user, accountConfig.getProviderType()).isPresent()) { throw new IllegalArgumentException("2FA provider is already configured"); } @@ -154,7 +152,7 @@ public class TwoFactorAuthConfigController extends BaseController { verificationSuccess = true; } if (verificationSuccess) { - return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig); + return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user, accountConfig); } else { throw new IllegalArgumentException("Verification code is incorrect"); } @@ -162,42 +160,41 @@ public class TwoFactorAuthConfigController extends BaseController { @ApiOperation(value = "Update 2FA account config (updateTwoFaAccountConfig)", notes = "Update config for a given provider type. \n" + - "Update request example:\n" + - "```\n{\n \"useByDefault\": true\n}\n```\n" + - "Returns whole account's 2FA settings object.\n" + - ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) + "Update request example:\n" + + "```\n{\n \"useByDefault\": true\n}\n```\n" + + "Returns whole account's 2FA settings object.\n" + + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PutMapping("/account/config") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public AccountTwoFaSettings updateTwoFaAccountConfig(@RequestParam TwoFaProviderType providerType, @RequestBody TwoFaAccountConfigUpdateRequest updateRequest) throws ThingsboardException { SecurityUser user = getCurrentUser(); - TwoFaAccountConfig accountConfig = twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType) + TwoFaAccountConfig accountConfig = twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user, providerType) .orElseThrow(() -> new IllegalArgumentException("Config for " + providerType + " 2FA provider not found")); accountConfig.setUseByDefault(updateRequest.isUseByDefault()); - return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig); + return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user, accountConfig); } @ApiOperation(value = "Delete 2FA account config (deleteTwoFaAccountConfig)", notes = "Delete 2FA config for a given 2FA provider type. \n" + - "Returns whole account's 2FA settings object.\n" + - ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) + "Returns whole account's 2FA settings object.\n" + + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @DeleteMapping("/account/config") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public AccountTwoFaSettings deleteTwoFaAccountConfig(@RequestParam TwoFaProviderType providerType) throws ThingsboardException { SecurityUser user = getCurrentUser(); - return twoFaConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType); + return twoFaConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user, providerType); } - @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes = "Get the list of provider types available for user to use (the ones configured by tenant or sysadmin).\n" + - "Example of response:\n" + - "```\n[\n \"TOTP\",\n \"EMAIL\",\n \"SMS\"\n]\n```" + - ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER + "Example of response:\n" + + "```\n[\n \"TOTP\",\n \"EMAIL\",\n \"SMS\"\n]\n```" + + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER ) @GetMapping("/providers") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')") public List getAvailableTwoFaProviders() throws ThingsboardException { return twoFaConfigManager.getPlatformTwoFaSettings(getTenantId(), true) .map(PlatformTwoFaSettings::getProviders).orElse(Collections.emptyList()).stream() @@ -205,7 +202,6 @@ public class TwoFactorAuthConfigController extends BaseController { .collect(Collectors.toList()); } - @ApiOperation(value = "Get platform 2FA settings (getPlatformTwoFaSettings)", notes = "Get platform settings for 2FA. The settings are described for savePlatformTwoFaSettings API method. " + "If 2FA is not configured, then an empty response will be returned." + @@ -260,11 +256,10 @@ public class TwoFactorAuthConfigController extends BaseController { @PostMapping("/settings") @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") public PlatformTwoFaSettings savePlatformTwoFaSettings(@Parameter(description = "Settings value", required = true) - @RequestBody PlatformTwoFaSettings twoFaSettings) throws ThingsboardException { + @RequestBody PlatformTwoFaSettings twoFaSettings) throws ThingsboardException { return twoFaConfigManager.savePlatformTwoFaSettings(getTenantId(), twoFaSettings); } - @Data public static class TwoFaAccountConfigUpdateRequest { private boolean useByDefault; diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java index 80c88bfc9b..0d2af1a4f1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -28,7 +28,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.ActionType; -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.common.data.security.model.mfa.PlatformTwoFaSettings; @@ -64,7 +63,6 @@ public class TwoFactorAuthController extends BaseController { private final SystemSecurityService systemSecurityService; private final UserService userService; - @ApiOperation(value = "Request 2FA verification code (requestTwoFaVerificationCode)", notes = "Request 2FA verification code." + NEW_LINE + "To make a request to this endpoint, you need an access token with the scope of PRE_VERIFICATION_TOKEN, " + @@ -92,30 +90,28 @@ public class TwoFactorAuthController extends BaseController { SecurityUser user = getCurrentUser(); boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, providerType, verificationCode, true); if (verificationSuccess) { - systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, null); - user = new SecurityUser(userService.findUserById(user.getTenantId(), user.getId()), true, user.getUserPrincipal()); - return tokenFactory.createTokenPair(user); + logLogInAction(servletRequest, user, null); + return createTokenPair(user); } else { - ThingsboardException error = new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.BAD_REQUEST_PARAMS); - systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, error); + IllegalArgumentException error = new IllegalArgumentException("Verification code is incorrect"); + logLogInAction(servletRequest, user, error); throw error; } } - @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes = "Get the list of 2FA provider infos available for user to use. Example:\n" + - "```\n[\n" + - " {\n \"type\": \"EMAIL\",\n \"default\": true,\n \"contact\": \"ab*****ko@gmail.com\"\n },\n" + - " {\n \"type\": \"TOTP\",\n \"default\": false,\n \"contact\": null\n },\n" + - " {\n \"type\": \"SMS\",\n \"default\": false,\n \"contact\": \"+38********12\"\n }\n" + - "]\n```") + "```\n[\n" + + " {\n \"type\": \"EMAIL\",\n \"default\": true,\n \"contact\": \"ab*****ko@gmail.com\"\n },\n" + + " {\n \"type\": \"TOTP\",\n \"default\": false,\n \"contact\": null\n },\n" + + " {\n \"type\": \"SMS\",\n \"default\": false,\n \"contact\": \"+38********12\"\n }\n" + + "]\n```") @GetMapping("/providers") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") public List getAvailableTwoFaProviders() throws ThingsboardException { SecurityUser user = getCurrentUser(); Optional platformTwoFaSettings = twoFaConfigManager.getPlatformTwoFaSettings(user.getTenantId(), true); - return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user.getId()) + return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user) .map(settings -> settings.getConfigs().values()).orElse(Collections.emptyList()) .stream().map(config -> { String contact = null; @@ -139,6 +135,32 @@ public class TwoFactorAuthController extends BaseController { .collect(Collectors.toList()); } + @ApiOperation(value = "Get regular token pair after successfully configuring 2FA", + notes = "Checks 2FA is configured, returning token pair on success.") + @PostMapping("/login") + @PreAuthorize("hasAuthority('MFA_CONFIGURATION_TOKEN')") + public JwtPair authenticateByTwoFaConfigurationToken(HttpServletRequest servletRequest) throws ThingsboardException { + SecurityUser user = getCurrentUser(); + if (twoFactorAuthService.isTwoFaEnabled(user.getTenantId(), user)) { + logLogInAction(servletRequest, user, null); + return createTokenPair(user); + } else { + IllegalArgumentException error = new IllegalArgumentException("2FA is not configured"); + logLogInAction(servletRequest, user, error); + throw error; + } + } + + private JwtPair createTokenPair(SecurityUser user) { + log.debug("[{}][{}] Creating token pair for user", user.getTenantId(), user.getId()); + user = new SecurityUser(userService.findUserById(user.getTenantId(), user.getId()), true, user.getUserPrincipal()); + return tokenFactory.createTokenPair(user); + } + + private void logLogInAction(HttpServletRequest servletRequest, SecurityUser user, Exception error) { + systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, error); + } + @Data @AllArgsConstructor @Builder diff --git a/application/src/main/java/org/thingsboard/server/controller/UserController.java b/application/src/main/java/org/thingsboard/server/controller/UserController.java index b9cb0c88b3..a2a7c993e9 100644 --- a/application/src/main/java/org/thingsboard/server/controller/UserController.java +++ b/application/src/main/java/org/thingsboard/server/controller/UserController.java @@ -266,6 +266,9 @@ public class UserController extends BaseController { if (user.getAuthority() == Authority.SYS_ADMIN && getCurrentUser().getId().equals(userId)) { throw new ThingsboardException("Sysadmin is not allowed to delete himself", ThingsboardErrorCode.PERMISSION_DENIED); } + if (user.getAuthority() == Authority.TENANT_ADMIN && userService.countTenantAdmins(user.getTenantId()) == 1) { + throw new ThingsboardException("At least one tenant administrator must remain!", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } tbUserService.delete(getTenantId(), getCurrentUser().getCustomerId(), user, getCurrentUser()); } diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java index d6252f57a6..639e2025fe 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java @@ -15,7 +15,13 @@ */ package org.thingsboard.server.service.ai; +import com.fasterxml.jackson.core.io.JsonStringEncoder; import com.google.common.util.concurrent.FluentFuture; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.Content; +import dev.langchain4j.data.message.TextContent; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.ModelProvider; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.response.ChatResponse; @@ -24,6 +30,9 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; +import java.util.List; +import java.util.stream.Collectors; + @Service @RequiredArgsConstructor class AiChatModelServiceImpl implements AiChatModelService { @@ -34,7 +43,39 @@ class AiChatModelServiceImpl implements AiChatModelService { @Override public > FluentFuture sendChatRequestAsync(AiChatModelConfig chatModelConfig, ChatRequest chatRequest) { ChatModel langChainChatModel = chatModelConfig.configure(chatModelConfigurer); + if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) { + chatRequest = prepareGithubChatRequest(chatRequest); + } return aiRequestsExecutor.sendChatRequestAsync(langChainChatModel, chatRequest); } + private ChatRequest prepareGithubChatRequest(ChatRequest chatRequest) { + List messages = chatRequest.messages().stream() + .map(this::prepareUserMessage) + .collect(Collectors.toList()); + + return ChatRequest.builder() + .messages(messages) + .responseFormat(chatRequest.responseFormat()) + .build(); + } + + private ChatMessage prepareUserMessage(ChatMessage message) { + if (message instanceof UserMessage userMessage) { + List newContents = userMessage.contents().stream() + .map(this::prepareContent) + .collect(Collectors.toList()); + + return UserMessage.from(newContents); + } + return message; + } + + private Content prepareContent(Content content) { + if (content instanceof TextContent txt) { + return new TextContent(new String(JsonStringEncoder.getInstance().quoteAsString(txt.text()))); + } + return content; + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java index 69dd98f47f..c631f1a3d0 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -32,8 +32,10 @@ import dev.langchain4j.model.chat.request.ChatRequestParameters; import dev.langchain4j.model.github.GitHubModelsChatModel; import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel; import dev.langchain4j.model.mistralai.MistralAiChatModel; +import dev.langchain4j.model.ollama.OllamaChatModel; import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; +import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; @@ -43,10 +45,12 @@ import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelC import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; @@ -54,7 +58,11 @@ import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.Base64; + +import static java.util.Collections.singletonMap; @Component class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigurer { @@ -62,6 +70,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) { return OpenAiChatModel.builder() + .baseUrl(chatModelConfig.providerConfig().baseUrl()) .apiKey(chatModelConfig.providerConfig().apiKey()) .modelName(chatModelConfig.modelId()) .temperature(chatModelConfig.temperature()) @@ -134,7 +143,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur // set request timeout from model config if (chatModelConfig.timeoutSeconds() != null) { - retrySettings.setTotalTimeout(org.threeten.bp.Duration.ofSeconds(chatModelConfig.timeoutSeconds())); + retrySettings.setTotalTimeoutDuration(Duration.ofSeconds(chatModelConfig.timeoutSeconds())); } // set updated retry settings @@ -262,6 +271,35 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .build(); } + @Override + public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) { + var builder = OllamaChatModel.builder() + .baseUrl(chatModelConfig.providerConfig().baseUrl()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .topK(chatModelConfig.topK()) + .numCtx(chatModelConfig.contextLength()) + .numPredict(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()); + + var auth = chatModelConfig.providerConfig().auth(); + if (auth instanceof OllamaProviderConfig.OllamaAuth.Basic basicAuth) { + String credentials = basicAuth.username() + ":" + basicAuth.password(); + String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + builder.customHeaders(singletonMap(HttpHeaders.AUTHORIZATION, "Basic " + encodedCredentials)); + } else if (auth instanceof OllamaProviderConfig.OllamaAuth.Token tokenAuth) { + builder.customHeaders(singletonMap(HttpHeaders.AUTHORIZATION, "Bearer " + tokenAuth.token())); + } else if (auth instanceof OllamaProviderConfig.OllamaAuth.None) { + // do nothing + } else { + throw new UnsupportedOperationException("Unknown authentication type: " + auth.getClass().getSimpleName()); + } + + return builder.build(); + } + private static Duration toDuration(Integer timeoutSeconds) { return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null; } 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 3dedea999c..84025b3cee 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 @@ -442,13 +442,13 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService boolean check(long threshold, long warnThreshold, long value); } - private void checkStartOfNextCycle() { + public void checkStartOfNextCycle() { updateLock.lock(); try { long now = System.currentTimeMillis(); myUsageStates.values().forEach(state -> { if ((state.getNextCycleTs() < now) && (now - state.getNextCycleTs() < TimeUnit.HOURS.toMillis(1))) { - state.setCycles(state.getNextCycleTs(), SchedulerUtils.getStartOfNextNextMonth()); + state.setCycles(state.getNextCycleTs(), SchedulerUtils.getStartOfNextMonth()); if (log.isTraceEnabled()) { log.trace("[{}][{}] Updating state cycles (currentCycleTs={},nextCycleTs={})", state.getTenantId(), state.getEntityId(), state.getCurrentCycleTs(), state.getNextCycleTs()); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java index 45305ca9e3..45145720d6 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java @@ -24,9 +24,11 @@ import jakarta.annotation.PreDestroy; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; -import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.Aggregation; @@ -36,7 +38,9 @@ import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.TsKvEntry; -import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.relation.RelationService; @@ -44,20 +48,24 @@ import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.usagerecord.ApiLimitService; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; -import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.ExecutionException; +import java.util.function.Predicate; import java.util.stream.Collectors; +import static org.thingsboard.server.common.data.cf.CalculatedFieldType.PROPAGATION; +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY; import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY; +import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultAttributeEntry; import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultKvEntry; -import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createStateByType; import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformSingleValueArgument; @Data @@ -68,6 +76,7 @@ public abstract class AbstractCalculatedFieldProcessingService { protected final TimeseriesService timeseriesService; protected final ApiLimitService apiLimitService; protected final RelationService relationService; + protected final OwnerService ownerService; protected ListeningExecutorService calculatedFieldCallbackExecutor; @@ -86,28 +95,38 @@ public abstract class AbstractCalculatedFieldProcessingService { protected abstract String getExecutorNamePrefix(); - public ListenableFuture fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId) { - Map> argFutures = switch (ctx.getCalculatedField().getType()) { - case GEOFENCING -> fetchGeofencingCalculatedFieldArguments(ctx, entityId, false); - case SIMPLE, SCRIPT -> { - Map> futures = new HashMap<>(); - for (var entry : ctx.getArguments().entrySet()) { - var argEntityId = resolveEntityId(entityId, entry.getValue()); - var argValueFuture = fetchArgumentValue(ctx.getTenantId(), argEntityId, entry.getValue(), System.currentTimeMillis()); - futures.put(entry.getKey(), argValueFuture); - } - yield futures; - } + protected ListenableFuture> fetchArguments(CalculatedFieldCtx ctx, EntityId entityId, long ts) { + Map> argFutures = switch (ctx.getCfType()) { + case GEOFENCING -> fetchGeofencingCalculatedFieldArguments(ctx, entityId, false, ts); + case SIMPLE, SCRIPT, ALARM, PROPAGATION -> getBaseCalculatedFieldArguments(ctx, entityId, ts); + case RELATED_ENTITIES_AGGREGATION -> fetchRelatedEntitiesAggArguments(ctx, entityId, ts); }; - return Futures.whenAllComplete(argFutures.values()).call(() -> { - var result = createStateByType(ctx); - result.updateState(ctx, resolveArgumentFutures(argFutures)); - return result; - }, MoreExecutors.directExecutor()); + if (ctx.getCfType() == PROPAGATION) { + argFutures.put(PROPAGATION_CONFIG_ARGUMENT, fetchPropagationCalculatedFieldArgument(ctx, entityId)); + } + return Futures.whenAllComplete(argFutures.values()) + .call(() -> resolveArgumentFutures(argFutures), + MoreExecutors.directExecutor()); + } + + private Map> getBaseCalculatedFieldArguments(CalculatedFieldCtx ctx, EntityId entityId, long ts) { + Map> futures = new HashMap<>(); + for (var entry : ctx.getArguments().entrySet()) { + var argEntityId = resolveEntityId(ctx.getTenantId(), entityId, entry.getValue()); + var argValueFuture = fetchArgumentValue(ctx.getTenantId(), argEntityId, entry.getValue(), ts); + futures.put(entry.getKey(), argValueFuture); + } + return futures; } - protected EntityId resolveEntityId(EntityId entityId, Argument argument) { - return argument.getRefEntityId() != null ? argument.getRefEntityId() : entityId; + protected EntityId resolveEntityId(TenantId tenantId, EntityId entityId, Argument argument) { + if (argument.getRefEntityId() != null) { + return argument.getRefEntityId(); + } + if (!argument.hasOwnerSource()) { + return entityId; + } + return resolveOwnerArgument(tenantId, entityId); } protected Map resolveArgumentFutures(Map> argFutures) { @@ -127,18 +146,23 @@ public abstract class AbstractCalculatedFieldProcessingService { )); } - protected Map> fetchGeofencingCalculatedFieldArguments(CalculatedFieldCtx ctx, EntityId entityId, boolean dynamicArgumentsOnly) { + protected ListenableFuture fetchPropagationCalculatedFieldArgument(CalculatedFieldCtx ctx, EntityId entityId) { + ListenableFuture> propagationEntityIds = fromDynamicSource(ctx.getTenantId(), entityId, ctx.getPropagationArgument()); + return Futures.transform(propagationEntityIds, ArgumentEntry::createPropagationArgument, MoreExecutors.directExecutor()); + } + + protected Map> fetchGeofencingCalculatedFieldArguments(CalculatedFieldCtx ctx, EntityId entityId, boolean dynamicArgumentsOnly, long startTs) { Map> argFutures = new HashMap<>(); Set> entries = ctx.getArguments().entrySet(); if (dynamicArgumentsOnly) { entries = entries.stream() - .filter(entry -> entry.getValue().hasDynamicSource()) + .filter(entry -> entry.getValue().hasRelationQuerySource()) .collect(Collectors.toSet()); } for (var entry : entries) { switch (entry.getKey()) { case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> - argFutures.put(entry.getKey(), fetchArgumentValue(ctx.getTenantId(), entityId, entry.getValue(), System.currentTimeMillis())); + argFutures.put(entry.getKey(), fetchArgumentValue(ctx.getTenantId(), entityId, entry.getValue(), startTs)); default -> { var resolvedEntityIdsFuture = resolveGeofencingEntityIds(ctx.getTenantId(), entityId, entry); argFutures.put(entry.getKey(), Futures.transformAsync(resolvedEntityIdsFuture, resolvedEntityIds -> @@ -149,6 +173,41 @@ public abstract class AbstractCalculatedFieldProcessingService { return argFutures; } + protected Map> fetchRelatedEntitiesAggArguments(CalculatedFieldCtx ctx, EntityId entityId, long ts) { + if (!(ctx.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration config)) { + return Collections.emptyMap(); + } + ListenableFuture> relatedEntitiesFut = resolveRelatedEntities(ctx.getTenantId(), entityId, config.getRelation()); + + return config.getArguments().entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> Futures.transformAsync(relatedEntitiesFut, relatedEntities -> fetchRelatedEntitiesArgumentEntry(ctx.getTenantId(), relatedEntities, entry.getValue(), ts), MoreExecutors.directExecutor()) + )); + } + + protected ListenableFuture> resolveRelatedEntities(TenantId tenantId, EntityId entityId, RelationPathLevel relation) { + Predicate filter = entityRelation -> CalculatedField.isSupportedRefEntity(entityRelation.getFrom()) && CalculatedField.isSupportedRefEntity(entityRelation.getTo()); + ListenableFuture> relationsFut = relationService.findFilteredRelationsByPathQueryAsync(tenantId, new EntityRelationPathQuery(entityId, List.of(relation)), filter); + + return Futures.transform(relationsFut, relations -> { + if (relations == null) { + return Collections.emptyList(); + } + + return switch (relation.direction()) { + case FROM -> relations.stream() + .map(EntityRelation::getTo) + .toList(); + case TO -> relations.stream() + .map(EntityRelation::getFrom) + .findFirst() + .map(List::of) + .orElseGet(Collections::emptyList); + }; + }, calculatedFieldCallbackExecutor); + } + private ListenableFuture> resolveGeofencingEntityIds(TenantId tenantId, EntityId entityId, Map.Entry entry) { Argument value = entry.getValue(); if (value.getRefEntityId() != null) { @@ -157,26 +216,26 @@ public abstract class AbstractCalculatedFieldProcessingService { if (!value.hasDynamicSource()) { return Futures.immediateFuture(List.of(entityId)); } + return fromDynamicSource(tenantId, entityId, value); + } + + private ListenableFuture> fromDynamicSource(TenantId tenantId, EntityId entityId, Argument value) { var refDynamicSourceConfiguration = value.getRefDynamicSourceConfiguration(); return switch (refDynamicSourceConfiguration.getType()) { - case RELATION_QUERY -> { - var configuration = (RelationQueryDynamicSourceConfiguration) refDynamicSourceConfiguration; - if (configuration.isSimpleRelation()) { - yield switch (configuration.getDirection()) { - case FROM -> - Futures.transform(relationService.findByFromAndTypeAsync(tenantId, entityId, configuration.getRelationType(), RelationTypeGroup.COMMON), - configuration::resolveEntityIds, calculatedFieldCallbackExecutor); - case TO -> - Futures.transform(relationService.findByToAndTypeAsync(tenantId, entityId, configuration.getRelationType(), RelationTypeGroup.COMMON), - configuration::resolveEntityIds, calculatedFieldCallbackExecutor); - }; - } - yield Futures.transform(relationService.findByQuery(tenantId, configuration.toEntityRelationsQuery(entityId)), + case CURRENT_OWNER -> Futures.immediateFuture(List.of(resolveOwnerArgument(tenantId, entityId))); + case RELATION_PATH_QUERY -> { + var configuration = (RelationPathQueryDynamicSourceConfiguration) refDynamicSourceConfiguration; + Predicate filter = entityRelation -> CalculatedField.isSupportedRefEntity(entityRelation.getFrom()) && CalculatedField.isSupportedRefEntity(entityRelation.getTo()); + yield Futures.transform(relationService.findFilteredRelationsByPathQueryAsync(tenantId, configuration.toRelationPathQuery(entityId), filter), configuration::resolveEntityIds, calculatedFieldCallbackExecutor); } }; } + private EntityId resolveOwnerArgument(TenantId tenantId, EntityId entityId) { + return ownerService.getOwner(tenantId, entityId); + } + private ListenableFuture fetchGeofencingKvEntry(TenantId tenantId, List geofencingEntities, Argument argument) { if (argument.getRefEntityKey().getType() != ArgumentType.ATTRIBUTE) { throw new IllegalStateException("Unsupported argument key type: " + argument.getRefEntityKey().getType()); @@ -190,8 +249,7 @@ public abstract class AbstractCalculatedFieldProcessingService { argument.getRefEntityKey().getKey() ); return Futures.transform(attributesFuture, resultOpt -> - Map.entry(entityId, resultOpt.orElseGet(() -> - new BaseAttributeKvEntry(createDefaultKvEntry(argument), System.currentTimeMillis(), 0L))), + Map.entry(entityId, resultOpt.orElseGet(() -> createDefaultAttributeEntry(argument, System.currentTimeMillis()))), calculatedFieldCallbackExecutor ); }).collect(Collectors.toList()); @@ -202,6 +260,23 @@ public abstract class AbstractCalculatedFieldProcessingService { .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))), MoreExecutors.directExecutor()); } + public ListenableFuture fetchRelatedEntitiesArgumentEntry(TenantId tenantId, List aggEntities, Argument argument, long startTs) { + List>> futures = aggEntities.stream() + .map(entityId -> { + ListenableFuture argumentEntryFut = fetchArgumentValue(tenantId, entityId, argument, startTs); + return Futures.transform(argumentEntryFut, argumentEntry -> Map.entry(entityId, ArgumentEntry.createSingleValueArgument(entityId, argumentEntry)), MoreExecutors.directExecutor()); + }) + .toList(); + + ListenableFuture>> allFutures = Futures.allAsList(futures); + + return Futures.transform(allFutures, + entries -> ArgumentEntry.createAggArgument( + entries.stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)) + ), + MoreExecutors.directExecutor()); + } + protected ListenableFuture fetchArgumentValue(TenantId tenantId, EntityId entityId, Argument argument, long startTs) { return switch (argument.getRefEntityKey().getType()) { case TS_ROLLING -> fetchTsRolling(tenantId, entityId, argument, startTs); @@ -229,12 +304,12 @@ public abstract class AbstractCalculatedFieldProcessingService { return Futures.transform(attributeOptFuture, attrOpt -> { log.debug("[{}][{}] Fetched attribute for key {}: {}", tenantId, entityId, argument.getRefEntityKey(), attrOpt); - AttributeKvEntry attributeKvEntry = attrOpt.orElseGet(() -> new BaseAttributeKvEntry(createDefaultKvEntry(argument), defaultLastUpdateTs, 0L)); + AttributeKvEntry attributeKvEntry = attrOpt.orElseGet(() -> new BaseAttributeKvEntry(createDefaultKvEntry(argument), defaultLastUpdateTs, SingleValueArgumentEntry.DEFAULT_VERSION)); return transformSingleValueArgument(Optional.of(attributeKvEntry)); }, calculatedFieldCallbackExecutor); } - protected ListenableFuture fetchTsLatest(TenantId tenantId, EntityId entityId, Argument argument, long startTs) { + protected ListenableFuture fetchTsLatest(TenantId tenantId, EntityId entityId, Argument argument, long defaultTs) { String timeseriesKey = argument.getRefEntityKey().getKey(); log.trace("[{}][{}] Fetching latest timeseries {}", tenantId, entityId, timeseriesKey); return transformSingleValueArgument( @@ -242,7 +317,7 @@ public abstract class AbstractCalculatedFieldProcessingService { timeseriesService.findLatest(tenantId, entityId, timeseriesKey), result -> { log.debug("[{}][{}] Fetched latest timeseries {}: {}", tenantId, entityId, timeseriesKey, result); - return result.or(() -> Optional.of(new BasicTsKvEntry(System.currentTimeMillis(), createDefaultKvEntry(argument), 0L))); + return result.or(() -> Optional.of(new BasicTsKvEntry(defaultTs, createDefaultKvEntry(argument), SingleValueArgumentEntry.DEFAULT_VERSION))); }, calculatedFieldCallbackExecutor)); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldStateService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldStateService.java index 70b41f069e..e673577742 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldStateService.java @@ -15,10 +15,15 @@ */ package org.thingsboard.server.service.cf; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.calculatedField.CalculatedFieldStateRestoreMsg; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.exception.TenantNotFoundException; +import org.thingsboard.server.common.msg.CalculatedFieldStatePartitionRestoreMsg; +import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.exception.CalculatedFieldStateException; @@ -37,6 +42,7 @@ import java.util.stream.Collectors; import static org.thingsboard.server.utils.CalculatedFieldUtils.fromProto; import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto; +@Slf4j public abstract class AbstractCalculatedFieldStateService implements CalculatedFieldStateService { @Autowired @@ -56,25 +62,44 @@ public abstract class AbstractCalculatedFieldStateService implements CalculatedF protected abstract void doPersist(CalculatedFieldEntityCtxId stateId, CalculatedFieldStateProto stateMsgProto, TbCallback callback); @Override - public final void removeState(CalculatedFieldEntityCtxId stateId, TbCallback callback) { + public final void deleteState(CalculatedFieldEntityCtxId stateId, TbCallback callback) { doRemove(stateId, callback); } protected abstract void doRemove(CalculatedFieldEntityCtxId stateId, TbCallback callback); - protected void processRestoredState(CalculatedFieldStateProto stateMsg) { + protected void processRestoredState(CalculatedFieldStateProto stateMsg, TopicPartitionInfo partition) { var id = fromProto(stateMsg.getId()); - var state = fromProto(stateMsg); - processRestoredState(id, state); + if (partition == null) { + try { + partition = actorSystemContext.resolve(ServiceType.TB_RULE_ENGINE, DataConstants.CF_QUEUE_NAME, id.tenantId(), id.entityId()); + } catch (TenantNotFoundException e) { + log.debug("Skipping CF state msg for non-existing tenant {}", id.tenantId()); + return; + } + } + var state = fromProto(id, stateMsg); + processRestoredState(id, state, partition); } - protected void processRestoredState(CalculatedFieldEntityCtxId id, CalculatedFieldState state) { - actorSystemContext.tell(new CalculatedFieldStateRestoreMsg(id, state)); + protected void processRestoredState(CalculatedFieldEntityCtxId id, CalculatedFieldState state, TopicPartitionInfo partition) { + partition = partition.withTopic(DataConstants.CF_STATES_QUEUE_NAME); + actorSystemContext.tell(new CalculatedFieldStateRestoreMsg(id, state, partition)); } @Override public void restore(QueueKey queueKey, Set partitions) { - stateService.update(queueKey, partitions, null); + stateService.update(queueKey, partitions, new QueueStateService.RestoreCallback() { + @Override + public void onAllPartitionsRestored() { + } + + @Override + public void onPartitionRestored(TopicPartitionInfo partition) { + partition = partition.withTopic(DataConstants.CF_STATES_QUEUE_NAME); + actorSystemContext.tellWithHighPriority(new CalculatedFieldStatePartitionRestoreMsg(partition)); + } + }); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AlarmCalculatedFieldResult.java b/application/src/main/java/org/thingsboard/server/service/cf/AlarmCalculatedFieldResult.java new file mode 100644 index 0000000000..498a215e17 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/AlarmCalculatedFieldResult.java @@ -0,0 +1,82 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf; + +import lombok.Builder; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.action.TbAlarmResult; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +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.List; + +@Data +@Builder +@RequiredArgsConstructor +public class AlarmCalculatedFieldResult implements CalculatedFieldResult { + + private final TbAlarmResult alarmResult; + + @Override + public TbMsg toTbMsg(EntityId entityId, List cfIds) { + TbMsgType msgType; + TbMsgMetaData metaData = new TbMsgMetaData(); + if (alarmResult.isCreated()) { + msgType = TbMsgType.ALARM_CREATED; + metaData.putValue(DataConstants.IS_NEW_ALARM, Boolean.TRUE.toString()); + } else if (alarmResult.isUpdated()) { + msgType = TbMsgType.ALARM_UPDATED; + metaData.putValue(DataConstants.IS_EXISTING_ALARM, Boolean.TRUE.toString()); + } else if (alarmResult.isSeverityUpdated()) { + msgType = TbMsgType.ALARM_SEVERITY_UPDATED; + metaData.putValue(DataConstants.IS_EXISTING_ALARM, Boolean.TRUE.toString()); + metaData.putValue(DataConstants.IS_SEVERITY_UPDATED_ALARM, Boolean.TRUE.toString()); + } else { + msgType = TbMsgType.ALARM_CLEAR; + metaData.putValue(DataConstants.IS_CLEARED_ALARM, Boolean.TRUE.toString()); + } + if (alarmResult.getConditionRepeats() != null) { + metaData.putValue(DataConstants.ALARM_CONDITION_REPEATS, String.valueOf(alarmResult.getConditionRepeats())); + } + if (alarmResult.getConditionDuration() != null) { + metaData.putValue(DataConstants.ALARM_CONDITION_DURATION, String.valueOf(alarmResult.getConditionDuration())); + } + + return TbMsg.newMsg() + .type(msgType) + .originator(entityId) + .data(JacksonUtil.toString(alarmResult.getAlarm())) + .metaData(metaData) + .build(); + } + + @Override + public String stringValue() { + return alarmResult != null ? JacksonUtil.toString(alarmResult) : null; + } + + @Override + public boolean isEmpty() { + return alarmResult == null; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java index fb63432fed..27e989de70 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java @@ -23,6 +23,8 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import java.util.List; +import java.util.Set; +import java.util.function.Predicate; public interface CalculatedFieldCache { @@ -36,10 +38,26 @@ public interface CalculatedFieldCache { List getCalculatedFieldCtxsByEntityId(EntityId entityId); + List getAggCalculatedFieldCtxsByFilter(Predicate relatedEntityFilter); + + boolean hasCalculatedFields(TenantId tenantId, EntityId entityId, Predicate filter); + void addCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId); void updateCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId); void evict(CalculatedFieldId calculatedFieldId); + EntityId getProfileId(TenantId tenantId, EntityId entityId); + + Set getDynamicEntities(TenantId tenantId, EntityId entityId); + + void updateOwnerEntity(TenantId tenantId, EntityId entityId); + + void addOwnerEntity(TenantId tenantId, EntityId entityId); + + void evictEntity(EntityId entityId); + + void evictOwner(EntityId owner); + } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java index 86ed174485..15988c4b7b 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java @@ -25,20 +25,21 @@ import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; -import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; import java.util.List; import java.util.Map; public interface CalculatedFieldProcessingService { - ListenableFuture fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId); + ListenableFuture> fetchArguments(CalculatedFieldCtx ctx, EntityId entityId); Map fetchDynamicArgsFromDb(CalculatedFieldCtx ctx, EntityId entityId); + List fetchRelatedEntities(CalculatedFieldCtx ctx, EntityId entityId); + Map fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map arguments); - void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult calculationResult, List cfIds, TbCallback callback); + void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback); void pushMsgToLinks(CalculatedFieldTelemetryMsg msg, List linkedCalculatedFields, TbCallback callback); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java index c779c27419..c62d5dc6d5 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java @@ -15,27 +15,18 @@ */ package org.thingsboard.server.service.cf; -import com.fasterxml.jackson.databind.JsonNode; -import lombok.Data; -import org.thingsboard.server.common.data.AttributeScope; -import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.msg.TbMsg; -@Data -public final class CalculatedFieldResult { +import java.util.List; - private final OutputType type; - private final AttributeScope scope; - private final JsonNode result; +public interface CalculatedFieldResult { - public boolean isEmpty() { - return result == null || result.isMissingNode() || result.isNull() || - (result.isObject() && result.isEmpty()) || - (result.isArray() && result.isEmpty()) || - (result.isTextual() && result.asText().isEmpty()); - } + TbMsg toTbMsg(EntityId entityId, List cfIds); - public String toStringOrElseNull() { - return result == null ? null : result.toString(); - } + String stringValue(); + + boolean isEmpty(); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldStateService.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldStateService.java index d0b34f18e8..10276ac421 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldStateService.java @@ -33,7 +33,7 @@ public interface CalculatedFieldStateService { void persistState(CalculatedFieldEntityCtxId stateId, CalculatedFieldState state, TbCallback callback) throws CalculatedFieldStateException; - void removeState(CalculatedFieldEntityCtxId stateId, TbCallback callback); + void deleteState(CalculatedFieldEntityCtxId stateId, TbCallback callback); void restore(QueueKey queueKey, Set partitions); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index dfe30a0e55..4466b368b2 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -19,29 +19,36 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.util.ConcurrentReferenceHashMap; -import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageDataIterable; import org.thingsboard.server.dao.cf.CalculatedFieldService; -import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.dao.usagerecord.ApiLimitService; import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.profile.TbAssetProfileCache; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Predicate; @Service @Slf4j @@ -51,9 +58,11 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { private final ConcurrentReferenceHashMap calculatedFieldFetchLocks = new ConcurrentReferenceHashMap<>(); private final CalculatedFieldService calculatedFieldService; - private final TbelInvokeService tbelInvokeService; - private final ApiLimitService apiLimitService; - private final RelationService relationService; + private final TbAssetProfileCache assetProfileCache; + private final TbDeviceProfileCache deviceProfileCache; + @Lazy + private final ActorSystemContext systemContext; + private final OwnerService ownerService; private final ConcurrentMap calculatedFields = new ConcurrentHashMap<>(); private final ConcurrentMap> entityIdCalculatedFields = new ConcurrentHashMap<>(); @@ -61,6 +70,8 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { private final ConcurrentMap> entityIdCalculatedFieldLinks = new ConcurrentHashMap<>(); private final ConcurrentMap calculatedFieldsCtx = new ConcurrentHashMap<>(); + private final ConcurrentMap> ownerEntities = new ConcurrentHashMap<>(); + @Value("${queue.calculated_fields.init_fetch_pack_size:50000}") @Getter private int initFetchPackSize; @@ -71,19 +82,17 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { cfs.forEach(cf -> { if (cf != null) { calculatedFields.putIfAbsent(cf.getId(), cf); + List links = cf.getConfiguration().buildCalculatedFieldLinks(cf.getTenantId(), cf.getEntityId(), cf.getId()); + calculatedFieldLinks.put(cf.getId(), new CopyOnWriteArrayList<>(links)); } }); calculatedFields.values().forEach(cf -> { entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cf); }); - PageDataIterable cfls = new PageDataIterable<>(calculatedFieldService::findAllCalculatedFieldLinks, initFetchPackSize); - cfls.forEach(link -> { - calculatedFieldLinks.computeIfAbsent(link.getCalculatedFieldId(), id -> new CopyOnWriteArrayList<>()).add(link); - }); calculatedFieldLinks.values().stream() .flatMap(List::stream) .forEach(link -> - entityIdCalculatedFieldLinks.computeIfAbsent(link.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(link) + entityIdCalculatedFieldLinks.computeIfAbsent(link.entityId(), id -> new CopyOnWriteArrayList<>()).add(link) ); } @@ -113,7 +122,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { if (ctx == null) { CalculatedField calculatedField = getCalculatedField(calculatedFieldId); if (calculatedField != null) { - ctx = new CalculatedFieldCtx(calculatedField, tbelInvokeService, apiLimitService, relationService); + ctx = new CalculatedFieldCtx(calculatedField, systemContext); calculatedFieldsCtx.put(calculatedFieldId, ctx); log.debug("[{}] Put calculated field ctx into cache: {}", calculatedFieldId, ctx); } @@ -136,6 +145,40 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { .toList(); } + @Override + public List getAggCalculatedFieldCtxsByFilter(Predicate relatedEntityFilter) { + return calculatedFields.values().stream() + .filter(cf -> CalculatedFieldType.RELATED_ENTITIES_AGGREGATION.equals(cf.getType())) + .map(cf -> getCalculatedFieldCtx(cf.getId())) + .filter(relatedEntityFilter) + .toList(); + } + + @Override + public boolean hasCalculatedFields(TenantId tenantId, EntityId entityId, Predicate filter) { + List entityCfs = getCalculatedFieldCtxsByEntityId(entityId); + for (CalculatedFieldCtx ctx : entityCfs) { + if (filter.test(ctx)) { + return true; + } + } + + return hasCalculatedFieldsByProfile(tenantId, entityId, filter); + } + + public boolean hasCalculatedFieldsByProfile(TenantId tenantId, EntityId entityId, Predicate filter) { + EntityId profileId = getProfileId(tenantId, entityId); + if (profileId != null) { + List profileCfs = getCalculatedFieldCtxsByEntityId(profileId); + for (CalculatedFieldCtx ctx : profileCfs) { + if (filter.test(ctx)) { + return true; + } + } + } + return false; + } + @Override public void addCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId) { Lock lock = getFetchLock(calculatedFieldId); @@ -181,10 +224,57 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { log.debug("[{}] evict calculated field links from cache: {}", calculatedFieldId, oldCalculatedField); calculatedFieldsCtx.remove(calculatedFieldId); log.debug("[{}] evict calculated field ctx from cache: {}", calculatedFieldId, oldCalculatedField); - entityIdCalculatedFieldLinks.forEach((entityId, calculatedFieldLinks) -> calculatedFieldLinks.removeIf(link -> link.getCalculatedFieldId().equals(calculatedFieldId))); + entityIdCalculatedFieldLinks.forEach((entityId, calculatedFieldLinks) -> calculatedFieldLinks.removeIf(link -> link.calculatedFieldId().equals(calculatedFieldId))); log.debug("[{}] evict calculated field links from cached links by entity id: {}", calculatedFieldId, oldCalculatedField); } + @Override + public EntityId getProfileId(TenantId tenantId, EntityId entityId) { + return switch (entityId.getEntityType()) { + case ASSET -> assetProfileCache.get(tenantId, (AssetId) entityId).getId(); + case DEVICE -> deviceProfileCache.get(tenantId, (DeviceId) entityId).getId(); + default -> null; + }; + } + + @Override + public Set getDynamicEntities(TenantId tenantId, EntityId entityId) { + if (entityId != null && entityId.getEntityType().isOneOf(EntityType.CUSTOMER, EntityType.TENANT)) { + return getOwnedEntities(tenantId, entityId); + } + return Collections.emptySet(); + } + + @Override + public void addOwnerEntity(TenantId tenantId, EntityId entityId) { + EntityId owner = ownerService.getOwner(tenantId, entityId); + getOwnedEntities(tenantId, owner).add(entityId); + } + + @Override + public void updateOwnerEntity(TenantId tenantId, EntityId entityId) { + evictEntity(entityId); + addOwnerEntity(tenantId, entityId); + } + + @Override + public void evictEntity(EntityId entityId) { + ownerEntities.values().forEach(entities -> entities.remove(entityId)); + } + + @Override + public void evictOwner(EntityId owner) { + ownerEntities.remove(owner); + } + + private Set getOwnedEntities(TenantId tenantId, EntityId ownerId) { + return ownerEntities.computeIfAbsent(ownerId, owner -> { + Set entities = ConcurrentHashMap.newKeySet(); + entities.addAll(ownerService.getOwnedEntities(tenantId, ownerId)); + return entities; + }); + } + private Lock getFetchLock(CalculatedFieldId id) { return calculatedFieldFetchLocks.computeIfAbsent(id, __ -> new ReentrantLock()); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java index 17dca5dd64..9a8afb2054 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java @@ -23,15 +23,12 @@ import org.thingsboard.server.actors.calculatedField.MultipleTbCallback; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; -import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; 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 org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; @@ -51,15 +48,16 @@ import org.thingsboard.server.queue.util.TbRuleEngineComponent; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; -import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.ExecutionException; -import static org.thingsboard.server.common.data.DataConstants.SCOPE; +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto; @TbRuleEngineComponent @@ -74,9 +72,10 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF TimeseriesService timeseriesService, ApiLimitService apiLimitService, RelationService relationService, + OwnerService ownerService, TbClusterService clusterService, PartitionService partitionService) { - super(attributesService, timeseriesService, apiLimitService, relationService); + super(attributesService, timeseriesService, apiLimitService, relationService, ownerService); this.clusterService = clusterService; this.partitionService = partitionService; } @@ -87,27 +86,40 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF } @Override - public ListenableFuture fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId) { - return super.fetchStateFromDb(ctx, entityId); + public ListenableFuture> fetchArguments(CalculatedFieldCtx ctx, EntityId entityId) { + return super.fetchArguments(ctx, entityId, System.currentTimeMillis()); } @Override public Map fetchDynamicArgsFromDb(CalculatedFieldCtx ctx, EntityId entityId) { - // only geofencing calculated fields supports dynamic arguments scheduled updates - if (!ctx.getCalculatedField().getType().equals(CalculatedFieldType.GEOFENCING)) { - return Map.of(); + return switch (ctx.getCfType()) { + case GEOFENCING -> resolveArgumentFutures(fetchGeofencingCalculatedFieldArguments(ctx, entityId, true, System.currentTimeMillis())); + case PROPAGATION -> resolveArgumentFutures(Map.of(PROPAGATION_CONFIG_ARGUMENT, fetchPropagationCalculatedFieldArgument(ctx, entityId))); + default -> Collections.emptyMap(); + }; + } + + @Override + public List fetchRelatedEntities(CalculatedFieldCtx ctx, EntityId entityId) { + try { + if (ctx.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration config) { + return resolveRelatedEntities(ctx.getTenantId(), entityId, config.getRelation()).get(); + } + return Collections.emptyList(); + } catch (ExecutionException | InterruptedException e) { + Throwable cause = e.getCause(); + throw new RuntimeException("Failed to fetch related entities for entity [" + entityId + "]: " + cause.getMessage(), cause); } - return resolveArgumentFutures(fetchGeofencingCalculatedFieldArguments(ctx, entityId, true)); } @Override public Map fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map arguments) { Map> argFutures = new HashMap<>(); for (var entry : arguments.entrySet()) { - if (entry.getValue().hasDynamicSource()) { + if (entry.getValue().hasRelationQuerySource()) { continue; } - var argEntityId = resolveEntityId(entityId, entry.getValue()); + var argEntityId = resolveEntityId(tenantId, entityId, entry.getValue()); var argValueFuture = fetchArgumentValue(tenantId, argEntityId, entry.getValue(), System.currentTimeMillis()); argFutures.put(entry.getKey(), argValueFuture); } @@ -115,17 +127,36 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF } @Override - public void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult calculatedFieldResult, List cfIds, TbCallback callback) { + public void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback) { + if (!(result instanceof PropagationCalculatedFieldResult propagationCalculatedFieldResult)) { + TbMsg msg = result.toTbMsg(entityId, cfIds); + sendMsgToRuleEngine(tenantId, entityId, callback, msg); + return; + } + List propagationEntityIds = propagationCalculatedFieldResult.getPropagationEntityIds(); + if (propagationEntityIds.isEmpty()) { + callback.onSuccess(); + } + if (propagationEntityIds.size() == 1) { + EntityId propagationEntityId = propagationEntityIds.get(0); + TbMsg msg = result.toTbMsg(propagationEntityId, cfIds); + sendMsgToRuleEngine(tenantId, propagationEntityId, callback, msg); + return; + } + MultipleTbCallback multipleTbCallback = new MultipleTbCallback(propagationEntityIds.size(), callback); + for (var propagationEntityId : propagationEntityIds) { + TbMsg msg = result.toTbMsg(propagationEntityId, cfIds); + sendMsgToRuleEngine(tenantId, propagationEntityId, multipleTbCallback, msg); + } + } + + private void sendMsgToRuleEngine(TenantId tenantId, EntityId entityId, TbCallback callback, TbMsg msg) { try { - OutputType type = calculatedFieldResult.getType(); - TbMsgType msgType = OutputType.ATTRIBUTES.equals(type) ? TbMsgType.POST_ATTRIBUTES_REQUEST : TbMsgType.POST_TELEMETRY_REQUEST; - TbMsgMetaData md = OutputType.ATTRIBUTES.equals(type) ? new TbMsgMetaData(Map.of(SCOPE, calculatedFieldResult.getScope().name())) : TbMsgMetaData.EMPTY; - TbMsg msg = TbMsg.newMsg().type(msgType).originator(entityId).previousCalculatedFieldIds(cfIds).metaData(md).data(calculatedFieldResult.toStringOrElseNull()).build(); clusterService.pushMsgToRuleEngine(tenantId, entityId, msg, new TbQueueCallback() { @Override public void onSuccess(TbQueueMsgMetadata metadata) { - callback.onSuccess(); log.trace("[{}][{}] Pushed message to rule engine: {} ", tenantId, entityId, msg); + callback.onSuccess(); } @Override @@ -134,7 +165,7 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF } }); } catch (Exception e) { - log.warn("[{}][{}] Failed to push message to rule engine. CalculatedFieldResult: {}", tenantId, entityId, calculatedFieldResult, e); + log.warn("[{}][{}] Failed to push message to rule engine: {}", tenantId, entityId, msg, e); callback.onFailure(e); } } @@ -208,6 +239,7 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF public void onFailure(Throwable t) { callback.onFailure(t); } + } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java index fc5d75be56..0236de552f 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java @@ -25,10 +25,10 @@ import org.thingsboard.rule.engine.api.TimeseriesDeleteRequest; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; -import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; -import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; @@ -36,7 +36,12 @@ import org.thingsboard.server.common.data.kv.AttributesSaveResult; import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.common.util.ProtoUtils; +import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.gen.transport.TransportProtos.AttributeScopeProto; import org.thingsboard.server.gen.transport.TransportProtos.AttributeValueProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; @@ -45,13 +50,9 @@ import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto; import org.thingsboard.server.queue.TbQueueCallback; import org.thingsboard.server.queue.TbQueueMsgMetadata; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; -import org.thingsboard.server.service.profile.TbAssetProfileCache; -import org.thingsboard.server.service.profile.TbDeviceProfileCache; import java.util.Collections; -import java.util.EnumSet; import java.util.List; -import java.util.Set; import java.util.UUID; import java.util.function.Predicate; import java.util.function.Supplier; @@ -74,14 +75,9 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS } }; - private final TbAssetProfileCache assetProfileCache; - private final TbDeviceProfileCache deviceProfileCache; private final CalculatedFieldCache calculatedFieldCache; private final TbClusterService clusterService; - - private static final Set supportedReferencedEntities = EnumSet.of( - EntityType.DEVICE, EntityType.ASSET, EntityType.CUSTOMER, EntityType.TENANT - ); + private final RelationService relationService; @Override public void pushRequestToQueue(TimeseriesSaveRequest request, TimeseriesSaveResult result, FutureCallback callback) { @@ -91,6 +87,8 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS checkEntityAndPushToQueue(tenantId, entityId, cf -> cf.matches(entries), cf -> cf.linkMatches(entityId, entries), + cf -> cf.dynamicSourceMatches(request.getEntries()), + cf -> cf.relatedEntityMatches(entries), () -> toCalculatedFieldTelemetryMsgProto(request, result), callback); } @@ -108,6 +106,8 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS checkEntityAndPushToQueue(tenantId, entityId, cf -> cf.matches(entries, scope), cf -> cf.linkMatches(entityId, entries, scope), + cf -> cf.dynamicSourceMatches(request.getEntries(), request.getScope()), + cf -> cf.relatedEntityMatches(entries, scope), () -> toCalculatedFieldTelemetryMsgProto(request, result), callback); } @@ -124,6 +124,8 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS checkEntityAndPushToQueue(tenantId, entityId, cf -> cf.matchesKeys(result, scope), cf -> cf.linkMatchesAttrKeys(entityId, result, scope), + cf -> cf.matchesDynamicSourceKeys(result, request.getScope()), + cf -> cf.matchesRelatedEntityKeys(result, scope), () -> toCalculatedFieldTelemetryMsgProto(request, result), callback); } @@ -134,16 +136,21 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS checkEntityAndPushToQueue(tenantId, entityId, cf -> cf.matchesKeys(result), cf -> cf.linkMatchesTsKeys(entityId, result), + cf -> cf.matchesDynamicSourceKeys(result), + cf -> cf.matchesRelatedEntityKeys(result), () -> toCalculatedFieldTelemetryMsgProto(request, result), callback); } private void checkEntityAndPushToQueue(TenantId tenantId, EntityId entityId, - Predicate mainEntityFilter, Predicate linkedEntityFilter, + Predicate mainEntityFilter, + Predicate linkedEntityFilter, + Predicate dynamicSourceFilter, + Predicate relatedEntityFilter, Supplier msg, FutureCallback callback) { if (EntityType.TENANT.equals(entityId.getEntityType())) { tenantId = (TenantId) entityId; } - boolean send = checkEntityForCalculatedFields(tenantId, entityId, mainEntityFilter, linkedEntityFilter); + boolean send = checkEntityForCalculatedFields(tenantId, entityId, mainEntityFilter, linkedEntityFilter, dynamicSourceFilter, relatedEntityFilter); if (send) { ToCalculatedFieldMsg calculatedFieldMsg = msg.get(); clusterService.pushMsgToCalculatedFields(tenantId, entityId, calculatedFieldMsg, wrap(callback)); @@ -154,44 +161,56 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS } } - private boolean checkEntityForCalculatedFields(TenantId tenantId, EntityId entityId, Predicate filter, Predicate linkedEntityFilter) { - if (!supportedReferencedEntities.contains(entityId.getEntityType())) { + private boolean checkEntityForCalculatedFields(TenantId tenantId, EntityId entityId, Predicate filter, Predicate linkedEntityFilter, Predicate dynamicSourceFilter, Predicate relatedEntityFilter) { + if (!CalculatedField.isSupportedRefEntity(entityId)) { return false; } - List entityCfs = calculatedFieldCache.getCalculatedFieldCtxsByEntityId(entityId); - for (CalculatedFieldCtx ctx : entityCfs) { - if (filter.test(ctx)) { - return true; - } - } - EntityId profileId = getProfileId(tenantId, entityId); - if (profileId != null) { - List profileCfs = calculatedFieldCache.getCalculatedFieldCtxsByEntityId(profileId); - for (CalculatedFieldCtx ctx : profileCfs) { - if (filter.test(ctx)) { - return true; - } - } + if (calculatedFieldCache.hasCalculatedFields(tenantId, entityId, filter)) { + return true; } List links = calculatedFieldCache.getCalculatedFieldLinksByEntityId(entityId); for (CalculatedFieldLink link : links) { - CalculatedFieldCtx ctx = calculatedFieldCache.getCalculatedFieldCtx(link.getCalculatedFieldId()); + CalculatedFieldCtx ctx = calculatedFieldCache.getCalculatedFieldCtx(link.calculatedFieldId()); if (ctx != null && linkedEntityFilter.test(ctx)) { return true; } } - return false; - } + for (EntityId dynamicEntity : calculatedFieldCache.getDynamicEntities(tenantId, entityId)) { + if (calculatedFieldCache.getCalculatedFieldCtxsByEntityId(dynamicEntity).stream().anyMatch(dynamicSourceFilter)) { + return true; + } + EntityId dynamicEntityProfileId = calculatedFieldCache.getProfileId(tenantId, dynamicEntity); + if (calculatedFieldCache.getCalculatedFieldCtxsByEntityId(dynamicEntityProfileId).stream().anyMatch(dynamicSourceFilter)) { + return true; + } + } - private EntityId getProfileId(TenantId tenantId, EntityId entityId) { - return switch (entityId.getEntityType()) { - case ASSET -> assetProfileCache.get(tenantId, (AssetId) entityId).getId(); - case DEVICE -> deviceProfileCache.get(tenantId, (DeviceId) entityId).getId(); - default -> null; - }; + List cfCtxs = calculatedFieldCache.getAggCalculatedFieldCtxsByFilter(relatedEntityFilter); + for (CalculatedFieldCtx cfCtx : cfCtxs) { + if (cfCtx.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration aggConfig) { + RelationPathLevel relation = aggConfig.getRelation(); + EntitySearchDirection inverseDirection = switch (relation.direction()) { + case FROM -> EntitySearchDirection.TO; + case TO -> EntitySearchDirection.FROM; + }; + RelationPathLevel inverseRelation = new RelationPathLevel(inverseDirection, relation.relationType()); + List byRelationPathQuery = relationService.findByRelationPathQuery(tenantId, new EntityRelationPathQuery(entityId, List.of(inverseRelation))); + if (!byRelationPathQuery.isEmpty()) { + EntityId cfEntityId = cfCtx.getEntityId(); + for (EntityRelation entityRelation : byRelationPathQuery) { + EntityId relatedId = (inverseDirection == EntitySearchDirection.FROM) ? entityRelation.getTo() : entityRelation.getFrom(); + if (cfEntityId.equals(relatedId) || cfEntityId.equals(calculatedFieldCache.getProfileId(tenantId, relatedId))) { + return true; + } + } + } + } + } + + return false; } private ToCalculatedFieldMsg toCalculatedFieldTelemetryMsgProto(TimeseriesSaveRequest request, TimeseriesSaveResult result) { @@ -305,6 +324,7 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS public void onFailure(Throwable t) { callback.onFailure(t); } + } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/OwnerService.java b/application/src/main/java/org/thingsboard/server/service/cf/OwnerService.java new file mode 100644 index 0000000000..269d90e5e4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/OwnerService.java @@ -0,0 +1,76 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.DeviceInfo; +import org.thingsboard.server.common.data.DeviceInfoFilter; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageDataIterable; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.device.DeviceService; + +import java.util.HashSet; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class OwnerService { + + private final DeviceService deviceService; + private final AssetService assetService; + private final CustomerService customerService; + + public EntityId getOwner(TenantId tenantId, EntityId entityId) { + return switch (entityId.getEntityType()) { + case DEVICE -> deviceService.findDeviceById(tenantId, (DeviceId) entityId).getOwnerId(); + case ASSET -> assetService.findAssetById(tenantId, (AssetId) entityId).getOwnerId(); + case CUSTOMER -> tenantId; + default -> throw new UnsupportedOperationException(); + }; + } + + public Set getOwnedEntities(TenantId tenantId, EntityId ownerId) { + Set ownedEntities = new HashSet<>(); + if (EntityType.CUSTOMER.equals(ownerId.getEntityType())) { + PageDataIterable deviceIdInfos = new PageDataIterable<>(pageLink -> deviceService.findDeviceInfosByFilter(DeviceInfoFilter.builder().tenantId(tenantId).customerId((CustomerId) ownerId).build(), pageLink), 1000); + deviceIdInfos.forEach(deviceInfo -> ownedEntities.add(deviceInfo.getId())); + + PageDataIterable assets = new PageDataIterable<>(pageLink -> assetService.findAssetsByTenantIdAndCustomerId(tenantId, (CustomerId) ownerId, pageLink), 1000); + assets.forEach(asset -> ownedEntities.add(asset.getId())); + } else if (EntityType.TENANT.equals(ownerId.getEntityType())) { + PageDataIterable deviceIdInfos = new PageDataIterable<>(pageLink -> deviceService.findDeviceInfosByFilter(DeviceInfoFilter.builder().tenantId((TenantId) ownerId).customerId(new CustomerId(CustomerId.NULL_UUID)).build(), pageLink), 1000); + deviceIdInfos.forEach(deviceInfo -> ownedEntities.add(deviceInfo.getId())); + + PageDataIterable assets = new PageDataIterable<>(pageLink -> assetService.findAssetsByTenantIdAndCustomerId((TenantId) ownerId, new CustomerId(CustomerId.NULL_UUID), pageLink), 1000); + assets.forEach(asset -> ownedEntities.add(asset.getId())); + + PageDataIterable customers = new PageDataIterable<>(pageLink -> customerService.findCustomersByTenantId((TenantId) ownerId, pageLink), 1000); + customers.forEach(customer -> ownedEntities.add(customer.getId())); + } + return ownedEntities; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java b/application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java new file mode 100644 index 0000000000..780fd220a7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf; + +import lombok.Builder; +import lombok.Data; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.util.CollectionsUtil; +import org.thingsboard.server.common.msg.TbMsg; + +import java.util.List; + +@Data +@Builder +public final class PropagationCalculatedFieldResult implements CalculatedFieldResult { + + private final List propagationEntityIds; + private final TelemetryCalculatedFieldResult result; + + @Override + public TbMsg toTbMsg(EntityId entityId, List cfIds) { + return result.toTbMsg(entityId, cfIds); + } + + @Override + public String stringValue() { + return result.stringValue(); + } + + @Override + public boolean isEmpty() { + return CollectionsUtil.isEmpty(propagationEntityIds) || result.isEmpty(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/TelemetryCalculatedFieldResult.java b/application/src/main/java/org/thingsboard/server/service/cf/TelemetryCalculatedFieldResult.java new file mode 100644 index 0000000000..e71e381807 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/TelemetryCalculatedFieldResult.java @@ -0,0 +1,76 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Builder; +import lombok.Data; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +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.List; +import java.util.Map; + +import static org.thingsboard.server.common.data.DataConstants.SCOPE; + +@Data +@Builder +public final class TelemetryCalculatedFieldResult implements CalculatedFieldResult { + + private final OutputType type; + private final AttributeScope scope; + private final JsonNode result; + + public static final TelemetryCalculatedFieldResult EMPTY = TelemetryCalculatedFieldResult.builder().result(null).build(); + + @Override + public TbMsg toTbMsg(EntityId entityId, List cfIds) { + TbMsgType msgType = switch (type) { + case ATTRIBUTES -> TbMsgType.POST_ATTRIBUTES_REQUEST; + case TIME_SERIES -> TbMsgType.POST_TELEMETRY_REQUEST; + }; + TbMsgMetaData metaData = switch (type) { + case ATTRIBUTES -> new TbMsgMetaData(Map.of(SCOPE, scope.name())); + case TIME_SERIES -> TbMsgMetaData.EMPTY; + }; + return TbMsg.newMsg() + .type(msgType) + .originator(entityId) + .previousCalculatedFieldIds(cfIds) + .data(stringValue()) + .metaData(metaData) + .build(); + } + + @Override + public String stringValue() { + return result == null ? null : result.toString(); + } + + @Override + public boolean isEmpty() { + return result == null || result.isMissingNode() || result.isNull() || + (result.isObject() && result.isEmpty()) || + (result.isArray() && result.isEmpty()) || + (result.isTextual() && result.asText().isEmpty()); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java index 2d43883131..b331c11a47 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java @@ -22,7 +22,9 @@ import org.thingsboard.script.api.tbel.TbelCfArg; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry; import java.util.List; import java.util.Map; @@ -35,7 +37,9 @@ import java.util.Map; @JsonSubTypes({ @JsonSubTypes.Type(value = SingleValueArgumentEntry.class, name = "SINGLE_VALUE"), @JsonSubTypes.Type(value = TsRollingArgumentEntry.class, name = "TS_ROLLING"), - @JsonSubTypes.Type(value = GeofencingArgumentEntry.class, name = "GEOFENCING") + @JsonSubTypes.Type(value = GeofencingArgumentEntry.class, name = "GEOFENCING"), + @JsonSubTypes.Type(value = PropagationArgumentEntry.class, name = "PROPAGATION"), + @JsonSubTypes.Type(value = RelatedEntitiesArgumentEntry.class, name = "RELATED_ENTITIES") }) public interface ArgumentEntry { @@ -58,6 +62,10 @@ public interface ArgumentEntry { return new SingleValueArgumentEntry(kvEntry); } + static ArgumentEntry createSingleValueArgument(EntityId entityId, ArgumentEntry argumentEntry) { + return new SingleValueArgumentEntry(entityId, argumentEntry); + } + static ArgumentEntry createTsRollingArgument(List kvEntries, int limit, long timeWindow) { return new TsRollingArgumentEntry(kvEntries, limit, timeWindow); } @@ -66,4 +74,12 @@ public interface ArgumentEntry { return new GeofencingArgumentEntry(entityIdkvEntryMap); } + static ArgumentEntry createPropagationArgument(List entityIds) { + return new PropagationArgumentEntry(entityIds); + } + + static ArgumentEntry createAggArgument(Map entityIdkvEntryMap) { + return new RelatedEntitiesArgumentEntry(entityIdkvEntryMap, false); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java index 876bfa2a3f..427df2bf5b 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java @@ -16,5 +16,5 @@ package org.thingsboard.server.service.cf.ctx.state; public enum ArgumentEntryType { - SINGLE_VALUE, TS_ROLLING, GEOFENCING + SINGLE_VALUE, TS_ROLLING, GEOFENCING, PROPAGATION, RELATED_ENTITIES } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java index 9a1d06cf24..20f944e433 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java @@ -15,42 +15,59 @@ */ package org.thingsboard.server.service.cf.ctx.state; -import lombok.AllArgsConstructor; -import lombok.Data; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Getter; +import lombok.Setter; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.actors.TbActorRef; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry; import org.thingsboard.server.utils.CalculatedFieldUtils; +import java.io.Closeable; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; -@Data -@AllArgsConstructor -public abstract class BaseCalculatedFieldState implements CalculatedFieldState { +@Getter +public abstract class BaseCalculatedFieldState implements CalculatedFieldState, Closeable { + protected final EntityId entityId; + protected CalculatedFieldCtx ctx; + protected TbActorRef actorCtx; protected List requiredArguments; - protected Map arguments; - protected boolean sizeExceedsLimit; + protected Map arguments = new HashMap<>(); + protected boolean sizeExceedsLimit; protected long latestTimestamp = -1; + protected ReadinessStatus readinessStatus; + + @Setter + private TopicPartitionInfo partition; - public BaseCalculatedFieldState(List requiredArguments) { - this.requiredArguments = requiredArguments; - this.arguments = new HashMap<>(); + public BaseCalculatedFieldState(EntityId entityId) { + this.entityId = entityId; } - public BaseCalculatedFieldState() { - this(new ArrayList<>(), new HashMap<>(), false, -1); + @Override + public void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx) { + this.ctx = ctx; + this.actorCtx = actorCtx; + this.requiredArguments = ctx.getArgNames(); + this.readinessStatus = checkReadiness(requiredArguments, arguments); } @Override - public boolean updateState(CalculatedFieldCtx ctx, Map argumentValues) { - if (arguments == null) { - arguments = new HashMap<>(); - } + public void init() { + } - boolean stateUpdated = false; + @Override + public Map update(Map argumentValues, CalculatedFieldCtx ctx) { + Map updatedArguments = null; for (Map.Entry entry : argumentValues.entrySet()) { String key = entry.getKey(); @@ -62,27 +79,45 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState { boolean entryUpdated; if (existingEntry == null || newEntry.isForceResetPrevious()) { - validateNewEntry(newEntry); - arguments.put(key, newEntry); + validateNewEntry(key, newEntry); + if (existingEntry instanceof RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry) { + relatedEntitiesArgumentEntry.updateEntry(newEntry); + } else { + arguments.put(key, newEntry); + } entryUpdated = true; } else { entryUpdated = existingEntry.updateEntry(newEntry); } if (entryUpdated) { - stateUpdated = true; + if (updatedArguments == null) { + updatedArguments = new HashMap<>(argumentValues.size()); + } + updatedArguments.put(key, newEntry); updateLastUpdateTimestamp(newEntry); } } - return stateUpdated; + if (updatedArguments == null) { + return Collections.emptyMap(); + } + readinessStatus = checkReadiness(requiredArguments, arguments); + return updatedArguments; + } + + @Override + public void reset() { // must reset everything dependent on arguments + requiredArguments = null; + arguments.clear(); + sizeExceedsLimit = false; + latestTimestamp = -1; } @Override public boolean isReady() { - return arguments.keySet().containsAll(requiredArguments) && - arguments.values().stream().noneMatch(ArgumentEntry::isEmpty); + return readinessStatus.ready(); } @Override @@ -93,7 +128,26 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState { } } - protected void validateNewEntry(ArgumentEntry newEntry) {} + @Override + public void close() { + } + + protected void validateNewEntry(String key, ArgumentEntry newEntry) { + } + + protected ObjectNode toSimpleResult(boolean useLatestTs, ObjectNode valuesNode) { + if (!useLatestTs) { + return valuesNode; + } + long latestTs = getLatestTimestamp(); + if (latestTs == -1) { + return valuesNode; + } + ObjectNode resultNode = JacksonUtil.newObjectNode(); + resultNode.put("ts", latestTs); + resultNode.set("values", valuesNode); + return resultNode; + } private void updateLastUpdateTimestamp(ArgumentEntry entry) { long newTs = this.latestTimestamp; @@ -106,4 +160,21 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState { this.latestTimestamp = Math.max(this.latestTimestamp, newTs); } + protected ReadinessStatus checkReadiness(List requiredArguments, Map currentArguments) { + if (currentArguments == null) { + return ReadinessStatus.from(requiredArguments); + } + List emptyArguments = null; + for (String requiredArgumentKey : requiredArguments) { + ArgumentEntry argumentEntry = currentArguments.get(requiredArgumentKey); + if (argumentEntry == null || argumentEntry.isEmpty()) { + if (emptyArguments == null) { + emptyArguments = new ArrayList<>(); + } + emptyArguments.add(requiredArgumentKey); + } + } + return ReadinessStatus.from(emptyArguments); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java index c2cc083853..0df8d5ebe8 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java @@ -15,44 +15,68 @@ */ package org.thingsboard.server.service.cf.ctx.state; +import com.google.common.util.concurrent.ListenableFuture; import lombok.Data; +import lombok.extern.slf4j.Slf4j; import net.objecthunter.exp4j.Expression; -import net.objecthunter.exp4j.ExpressionBuilder; import org.mvel2.MVEL; +import org.thingsboard.common.util.ExpressionUtils; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfCtx; +import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.TbActorRef; +import org.thingsboard.server.actors.calculatedField.CalculatedFieldReevaluateMsg; import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.alarm.rule.AlarmRule; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ExpressionBasedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BasicKvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.data.util.CollectionsUtil; import org.thingsboard.server.common.util.ProtoUtils; import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.dao.usagerecord.ApiLimitService; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; +import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; +import java.io.Closeable; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; - -import static org.thingsboard.common.util.ExpressionFunctionsUtil.userDefinedFunctions; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; @Data -public class CalculatedFieldCtx { +@Slf4j +public class CalculatedFieldCtx implements Closeable { private CalculatedField calculatedField; @@ -61,16 +85,23 @@ public class CalculatedFieldCtx { private EntityId entityId; private CalculatedFieldType cfType; private final Map arguments; - private final Map mainEntityArguments; - private final Map> linkedEntityArguments; + private final Map> mainEntityArguments; + private final Map>> linkedEntityArguments; + private final Map> dynamicEntityArguments; + private final Map> relatedEntityArguments; private final List argNames; private Output output; private String expression; private boolean useLatestTs; + private boolean requiresScheduledReevaluation; + + private ActorSystemContext systemContext; private TbelInvokeService tbelInvokeService; private RelationService relationService; - private CalculatedFieldScriptEngine calculatedFieldScriptEngine; - private ThreadLocal customExpression; + private AlarmSubscriptionService alarmService; + + private Map tbelExpressions; + private Map> simpleExpressions; private boolean initialized; @@ -78,10 +109,18 @@ public class CalculatedFieldCtx { private long maxStateSize; private long maxSingleValueArgumentSize; + private boolean relationQueryDynamicArguments; private List mainEntityGeofencingArgumentNames; - private List linkedEntityGeofencingArgumentNames; + private List linkedEntityAndCurrentOwnerGeofencingArgumentNames; + private List relatedEntityArgumentNames; + + private long scheduledUpdateIntervalMillis; + + private Argument propagationArgument; + private boolean applyExpressionForResolvedArguments; - public CalculatedFieldCtx(CalculatedField calculatedField, TbelInvokeService tbelInvokeService, ApiLimitService apiLimitService, RelationService relationService) { + public CalculatedFieldCtx(CalculatedField calculatedField, + ActorSystemContext systemContext) { this.calculatedField = calculatedField; this.cfId = calculatedField.getId(); @@ -91,25 +130,43 @@ public class CalculatedFieldCtx { this.arguments = new HashMap<>(); this.mainEntityArguments = new HashMap<>(); this.linkedEntityArguments = new HashMap<>(); + this.dynamicEntityArguments = new HashMap<>(); + this.relatedEntityArguments = new HashMap<>(); this.argNames = new ArrayList<>(); this.mainEntityGeofencingArgumentNames = new ArrayList<>(); - this.linkedEntityGeofencingArgumentNames = new ArrayList<>(); + this.linkedEntityAndCurrentOwnerGeofencingArgumentNames = new ArrayList<>(); + this.relatedEntityArgumentNames = new ArrayList<>(); this.output = calculatedField.getConfiguration().getOutput(); if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration argBasedConfig) { this.arguments.putAll(argBasedConfig.getArguments()); for (Map.Entry entry : arguments.entrySet()) { var refId = entry.getValue().getRefEntityId(); var refKey = entry.getValue().getRefEntityKey(); - if (refId == null && entry.getValue().hasDynamicSource()) { - continue; - } - if (refId == null || refId.equals(calculatedField.getEntityId())) { - mainEntityArguments.put(refKey, entry.getKey()); + if (refId == null) { + if (CalculatedFieldType.RELATED_ENTITIES_AGGREGATION.equals(cfType)) { + relatedEntityArguments.compute(refKey, (key, existingNames) -> CollectionsUtil.addToSet(existingNames, entry.getKey())); + continue; + } + if (entry.getValue().hasRelationQuerySource()) { + relationQueryDynamicArguments = true; + continue; + } + if (entry.getValue().hasOwnerSource()) { + dynamicEntityArguments.compute(refKey, (key, existingNames) -> CollectionsUtil.addToSet(existingNames, entry.getKey())); + } else { + mainEntityArguments.compute(refKey, (key, existingNames) -> CollectionsUtil.addToSet(existingNames, entry.getKey())); + } + } else if (refId.equals(calculatedField.getEntityId())) { + mainEntityArguments.compute(refKey, (key, existingNames) -> CollectionsUtil.addToSet(existingNames, entry.getKey())); } else { - linkedEntityArguments.computeIfAbsent(refId, key -> new HashMap<>()).put(refKey, entry.getKey()); + linkedEntityArguments.computeIfAbsent(refId, key -> new HashMap<>()) + .compute(refKey, (key, existingNames) -> CollectionsUtil.addToSet(existingNames, entry.getKey())); } } this.argNames.addAll(arguments.keySet()); + this.relatedEntityArgumentNames = relatedEntityArguments.values().stream() + .flatMap(Set::stream) + .collect(Collectors.toList()); if (argBasedConfig instanceof ExpressionBasedCalculatedFieldConfiguration expressionBasedConfig) { this.expression = expressionBasedConfig.getExpression(); this.useLatestTs = CalculatedFieldType.SIMPLE.equals(calculatedField.getType()) && ((SimpleCalculatedFieldConfiguration) argBasedConfig).isUseLatestTs(); @@ -120,54 +177,163 @@ public class CalculatedFieldCtx { mainEntityGeofencingArgumentNames.add(zoneGroupName); return; } - if (config.isLinkedCfEntitySource(entityId)) { - linkedEntityGeofencingArgumentNames.add(zoneGroupName); + if (config.isLinkedCfEntitySource(entityId) || config.hasCurrentOwnerSource()) { + linkedEntityAndCurrentOwnerGeofencingArgumentNames.add(zoneGroupName); } }); } + if (calculatedField.getConfiguration() instanceof PropagationCalculatedFieldConfiguration propagationConfig) { + propagationArgument = propagationConfig.toPropagationArgument(); + applyExpressionForResolvedArguments = propagationConfig.isApplyExpressionToResolvedArguments(); + relationQueryDynamicArguments = true; + } } - this.tbelInvokeService = tbelInvokeService; - this.relationService = relationService; - - this.maxDataPointsPerRollingArg = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg); - this.maxStateSize = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxStateSizeInKBytes) * 1024; - this.maxSingleValueArgumentSize = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxSingleValueArgumentSizeInKBytes) * 1024; + if (calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledConfig) { + this.scheduledUpdateIntervalMillis = scheduledConfig.isScheduledUpdateEnabled() ? TimeUnit.SECONDS.toMillis(scheduledConfig.getScheduledUpdateInterval()) : -1L; + } + this.requiresScheduledReevaluation = calculatedField.getConfiguration().requiresScheduledReevaluation(); + if (calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration aggConfig) { + this.useLatestTs = aggConfig.isUseLatestTs(); + } + this.systemContext = systemContext; + this.tbelInvokeService = systemContext.getTbelInvokeService(); + this.relationService = systemContext.getRelationService(); + this.alarmService = systemContext.getAlarmService(); + + this.maxDataPointsPerRollingArg = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg); // fixme why tenant profile update is not handled?? + this.maxStateSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxStateSizeInKBytes) * 1024; + this.maxSingleValueArgumentSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxSingleValueArgumentSizeInKBytes) * 1024; } public void init() { switch (cfType) { case SCRIPT -> { - try { - this.calculatedFieldScriptEngine = initEngine(tenantId, expression, tbelInvokeService); - initialized = true; - } catch (Exception e) { - throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.", e); - } + initTbelExpression(expression); + initialized = true; } case GEOFENCING -> initialized = true; case SIMPLE -> { - if (isValidExpression(expression)) { - this.customExpression = ThreadLocal.withInitial(() -> - new ExpressionBuilder(expression) - .functions(userDefinedFunctions) - .implicitMultiplication(true) - .variables(this.arguments.keySet()) - .build() - ); - initialized = true; - } else { - throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax."); + initSimpleExpression(expression); + initialized = true; + } + case ALARM -> { + AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration(); + configuration.getAllRules().map(rule -> rule.getValue().getCondition().getExpression()) + .forEach(expression -> { + if (expression instanceof TbelAlarmConditionExpression tbelExpression) { + initTbelExpression(tbelExpression.getExpression()); + } + }); + initialized = true; + } + case PROPAGATION -> { + if (applyExpressionForResolvedArguments) { + initTbelExpression(expression); } + initialized = true; + } + case RELATED_ENTITIES_AGGREGATION -> { + RelatedEntitiesAggregationCalculatedFieldConfiguration configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) calculatedField.getConfiguration(); + configuration.getMetrics().forEach((key, metric) -> { + if (metric.getInput() instanceof AggFunctionInput functionInput) { + initTbelExpression(functionInput.getFunction()); + } + String filter = metric.getFilter(); + if (filter != null && !filter.isEmpty()) { + initTbelExpression(filter); + } + }); + initialized = true; } } } - public void stop() { - if (calculatedFieldScriptEngine != null) { - calculatedFieldScriptEngine.destroy(); + public double evaluateSimpleExpression(Expression expression, CalculatedFieldState state) { + for (Map.Entry entry : state.getArguments().entrySet()) { + try { + BasicKvEntry kvEntry = ((SingleValueArgumentEntry) entry.getValue()).getKvEntryValue(); + double value = switch (kvEntry.getDataType()) { + case LONG -> kvEntry.getLongValue().map(Long::doubleValue).orElseThrow(); + case DOUBLE -> kvEntry.getDoubleValue().orElseThrow(); + case BOOLEAN -> kvEntry.getBooleanValue().map(b -> b ? 1.0 : 0.0).orElseThrow(); + case STRING, JSON -> Double.parseDouble(kvEntry.getValueAsString()); + }; + expression.setVariable(entry.getKey(), value); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Argument '" + entry.getKey() + "' is not a number."); + } } - if (customExpression != null) { - customExpression.remove(); + return expression.evaluate(); + } + + public ListenableFuture evaluateTbelExpression(String expression, CalculatedFieldState state) { + return evaluateTbelExpression(tbelExpressions.get(expression), state.getArguments(), state.getLatestTimestamp()); + } + + public ListenableFuture evaluateTbelExpression(CalculatedFieldScriptEngine expression, CalculatedFieldState state) { + return evaluateTbelExpression(expression, state.getArguments(), state.getLatestTimestamp()); + } + + public ListenableFuture evaluateTbelExpression(String expression, Map entries, long latestTimestamp) { + return evaluateTbelExpression(tbelExpressions.get(expression), entries, latestTimestamp); + } + + public ListenableFuture evaluateTbelExpression(CalculatedFieldScriptEngine expression, Map entries, long latestTimestamp) { + Map arguments = new LinkedHashMap<>(); + List args = new ArrayList<>(argNames.size() + 1); + args.add(new Object()); // first element is a ctx, but we will set it later; + for (String argName : argNames) { + var arg = toTbelArgument(argName, entries); + arguments.put(argName, arg); + if (arg instanceof TbelCfSingleValueArg svArg) { + args.add(svArg.getValue()); + } else { + args.add(arg); + } + } + args.set(0, new TbelCfCtx(arguments, latestTimestamp)); + + return expression.executeScriptAsync(args.toArray()); + } + + public ScheduledFuture scheduleReevaluation(long delayMs, TbActorRef actorCtx) { + log.debug("[{}] Scheduling CF reevaluation in {} ms", cfId, delayMs); + return systemContext.scheduleMsgWithDelay(actorCtx, new CalculatedFieldReevaluateMsg(tenantId, this), delayMs); + } + + private TbelCfArg toTbelArgument(String key, Map arguments) { + return arguments.get(key).toTbelCfArg(); + } + + private void initTbelExpression(String expression) { + if (tbelExpressions == null) { + tbelExpressions = new HashMap<>(); + } else if (tbelExpressions.containsKey(expression)) { + return; + } + try { + CalculatedFieldScriptEngine engine = initEngine(tenantId, expression, tbelInvokeService); + tbelExpressions.put(expression, engine); + } catch (Exception e) { + initialized = false; + throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.", e); + } + } + + private void initSimpleExpression(String expression) { + if (simpleExpressions == null) { + simpleExpressions = new HashMap<>(); + } else if (simpleExpressions.containsKey(expression)) { + return; + } + if (isValidExpression(expression)) { + ThreadLocal compiledExpression = ThreadLocal.withInitial(() -> + ExpressionUtils.createExpression(expression, this.arguments.keySet()) + ); + simpleExpressions.put(expression, compiledExpression); + } else { + initialized = false; + throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax."); } } @@ -214,7 +380,15 @@ public class CalculatedFieldCtx { return map != null && matchesTimeSeries(map, values); } - private boolean matchesAttributes(Map argMap, List values, AttributeScope scope) { + public boolean dynamicSourceMatches(List values) { + return matchesTimeSeries(dynamicEntityArguments, values); + } + + public boolean dynamicSourceMatches(List values, AttributeScope scope) { + return matchesAttributes(dynamicEntityArguments, values, scope); + } + + private boolean matchesAttributes(Map> argMap, List values, AttributeScope scope) { if (argMap.isEmpty() || values.isEmpty()) { return false; } @@ -228,7 +402,7 @@ public class CalculatedFieldCtx { return false; } - private boolean matchesTimeSeries(Map argMap, List values) { + private boolean matchesTimeSeries(Map> argMap, List values) { if (argMap.isEmpty() || values.isEmpty()) { return false; } @@ -257,7 +431,15 @@ public class CalculatedFieldCtx { return matchesTimeSeriesKeys(mainEntityArguments, keys); } - private boolean matchesAttributesKeys(Map argMap, List keys, AttributeScope scope) { + public boolean matchesDynamicSourceKeys(List keys, AttributeScope scope) { + return matchesAttributesKeys(dynamicEntityArguments, keys, scope); + } + + public boolean matchesDynamicSourceKeys(List keys) { + return matchesTimeSeriesKeys(dynamicEntityArguments, keys); + } + + private boolean matchesAttributesKeys(Map> argMap, List keys, AttributeScope scope) { if (argMap.isEmpty() || keys.isEmpty()) { return false; } @@ -272,7 +454,7 @@ public class CalculatedFieldCtx { return false; } - private boolean matchesTimeSeriesKeys(Map argMap, List keys) { + private boolean matchesTimeSeriesKeys(Map> argMap, List keys) { if (argMap.isEmpty() || keys.isEmpty()) { return false; } @@ -303,6 +485,60 @@ public class CalculatedFieldCtx { return map != null && matchesTimeSeriesKeys(map, keys); } + public boolean relatedEntityMatches(List values) { + return matchesTimeSeries(relatedEntityArguments, values); + } + + public boolean relatedEntityMatches(List values, AttributeScope scope) { + return matchesAttributes(relatedEntityArguments, values, scope); + } + + public boolean matchesRelatedEntityKeys(List keys, AttributeScope scope) { + return matchesAttributesKeys(relatedEntityArguments, keys, scope); + } + + public boolean matchesRelatedEntityKeys(List keys) { + return matchesTimeSeriesKeys(relatedEntityArguments, keys); + } + + public boolean relatedEntityMatches(CalculatedFieldTelemetryMsgProto proto) { + if (!proto.getTsDataList().isEmpty()) { + List updatedTelemetry = proto.getTsDataList().stream() + .map(ProtoUtils::fromProto) + .toList(); + return relatedEntityMatches(updatedTelemetry); + } else if (!proto.getAttrDataList().isEmpty()) { + AttributeScope scope = AttributeScope.valueOf(proto.getScope().name()); + List updatedTelemetry = proto.getAttrDataList().stream() + .map(ProtoUtils::fromProto) + .toList(); + return relatedEntityMatches(updatedTelemetry, scope); + } else if (!proto.getRemovedTsKeysList().isEmpty()) { + return matchesRelatedEntityKeys(proto.getRemovedTsKeysList()); + } else { + return matchesRelatedEntityKeys(proto.getRemovedAttrKeysList(), AttributeScope.valueOf(proto.getScope().name())); + } + } + + public boolean dynamicSourceMatches(CalculatedFieldTelemetryMsgProto proto) { + if (!proto.getTsDataList().isEmpty()) { + List updatedTelemetry = proto.getTsDataList().stream() + .map(ProtoUtils::fromProto) + .toList(); + return dynamicSourceMatches(updatedTelemetry); + } else if (!proto.getAttrDataList().isEmpty()) { + AttributeScope scope = AttributeScope.valueOf(proto.getScope().name()); + List updatedTelemetry = proto.getAttrDataList().stream() + .map(ProtoUtils::fromProto) + .toList(); + return dynamicSourceMatches(updatedTelemetry, scope); + } else if (!proto.getRemovedTsKeysList().isEmpty()) { + return matchesDynamicSourceKeys(proto.getRemovedTsKeysList()); + } else { + return matchesDynamicSourceKeys(proto.getRemovedAttrKeysList(), AttributeScope.valueOf(proto.getScope().name())); + } + } + public boolean linkMatches(EntityId entityId, CalculatedFieldTelemetryMsgProto proto) { if (!proto.getTsDataList().isEmpty()) { List updatedTelemetry = proto.getTsDataList().stream() @@ -322,34 +558,163 @@ public class CalculatedFieldCtx { } } + public Map> getLinkedAndDynamicArgs(EntityId entityId) { + var argNames = new HashMap>(); + var linkedArgNames = linkedEntityArguments.get(entityId); + if (linkedArgNames != null && !linkedArgNames.isEmpty()) { + argNames.putAll(linkedArgNames); + } + if (dynamicEntityArguments != null && !dynamicEntityArguments.isEmpty()) { + argNames.putAll(dynamicEntityArguments); + } + return argNames; + } + public CalculatedFieldEntityCtxId toCalculatedFieldEntityCtxId() { return new CalculatedFieldEntityCtxId(tenantId, cfId, entityId); } - public boolean hasOtherSignificantChanges(CalculatedFieldCtx other) { - boolean expressionChanged = calculatedField.getConfiguration() instanceof ExpressionBasedCalculatedFieldConfiguration && !expression.equals(other.expression); - boolean outputChanged = !output.equals(other.output); - return expressionChanged || outputChanged; + public boolean hasContextOnlyChanges(CalculatedFieldCtx other) { // has changes that do not require state reinit and will be picked up by the state on the fly + if (calculatedField.getConfiguration() instanceof ExpressionBasedCalculatedFieldConfiguration && !Objects.equals(expression, other.expression)) { + return true; + } + if (!Objects.equals(output, other.output)) { + return true; + } + if (calculatedField.getConfiguration() instanceof SimpleCalculatedFieldConfiguration thisConfig + && other.calculatedField.getConfiguration() instanceof SimpleCalculatedFieldConfiguration otherConfig + && thisConfig.isUseLatestTs() != otherConfig.isUseLatestTs()) { + return true; + } + if (cfType == CalculatedFieldType.ALARM) { + if (!calculatedField.getName().equals(other.getCalculatedField().getName())) { + return true; + } + + var thisConfig = (AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration(); + var otherConfig = (AlarmCalculatedFieldConfiguration) other.getCalculatedField().getConfiguration(); + if (!thisConfig.rulesEqual(otherConfig, AlarmRule::equals)) { + // if the rules have any changes not tracked by hasStateChanges + return true; + } + } + if (scheduledUpdateIntervalMillis != other.scheduledUpdateIntervalMillis) { + return true; + } + if (calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration thisConfig + && other.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration otherConfig + && (thisConfig.getDeduplicationIntervalInSec() != otherConfig.getDeduplicationIntervalInSec() || !thisConfig.getMetrics().equals(otherConfig.getMetrics()))) { + return true; + } + return false; } public boolean hasStateChanges(CalculatedFieldCtx other) { - boolean typeChanged = !cfType.equals(other.cfType); - boolean argumentsChanged = !arguments.equals(other.arguments); - return typeChanged || argumentsChanged; + if (!arguments.equals(other.arguments)) { + return true; + } + if (cfType == CalculatedFieldType.ALARM) { + var thisConfig = (AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration(); + var otherConfig = (AlarmCalculatedFieldConfiguration) other.getCalculatedField().getConfiguration(); + if (!thisConfig.rulesEqual(otherConfig, (thisRule, otherRule) -> { + return thisRule.getCondition().getType() == otherRule.getCondition().getType(); + })) { + // reinitializing only if the rule list changed, or if a condition type changed for any rule + return true; + } + } + if (hasGeofencingZoneGroupConfigurationChanges(other)) { + return true; + } + if (hasRelatedEntitiesAggregationConfigurationChanges(other)) { + return true; + } + return false; + } + + private boolean hasGeofencingZoneGroupConfigurationChanges(CalculatedFieldCtx other) { + if (calculatedField.getConfiguration() instanceof GeofencingCalculatedFieldConfiguration thisConfig + && other.calculatedField.getConfiguration() instanceof GeofencingCalculatedFieldConfiguration otherConfig) { + return !thisConfig.getZoneGroups().equals(otherConfig.getZoneGroups()); + } + return false; } - public boolean hasSchedulingConfigChanges(CalculatedFieldCtx other) { - if (calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration thisConfig - && other.calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration otherConfig) { - boolean refreshTriggerChanged = thisConfig.isScheduledUpdateEnabled() != otherConfig.isScheduledUpdateEnabled(); - boolean refreshIntervalChanged = thisConfig.getScheduledUpdateInterval() != otherConfig.getScheduledUpdateInterval(); - return refreshTriggerChanged || refreshIntervalChanged; + private boolean hasRelatedEntitiesAggregationConfigurationChanges(CalculatedFieldCtx other) { + if (calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration thisConfig + && other.calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration otherConfig) { + return !thisConfig.getRelation().equals(otherConfig.getRelation()); } return false; } + private boolean isScheduledUpdateEnabled() { + return scheduledUpdateIntervalMillis != -1; + } + + public boolean shouldFetchRelationQueryDynamicArgumentsFromDb(CalculatedFieldState state) { + if (!relationQueryDynamicArguments) { + return false; + } + return switch (cfType) { + case PROPAGATION -> true; + case GEOFENCING -> { + if (!isScheduledUpdateEnabled()) { + yield false; + } + var geofencingState = (GeofencingCalculatedFieldState) state; + if (geofencingState.getLastDynamicArgumentsRefreshTs() == -1L) { + yield true; + } + yield geofencingState.getLastDynamicArgumentsRefreshTs() < + System.currentTimeMillis() - scheduledUpdateIntervalMillis; + } + default -> false; + }; + } + + public boolean shouldFetchEntityRelations(CalculatedFieldState state) { + if (!(state instanceof RelatedEntitiesAggregationCalculatedFieldState relatedEntitiesAggState)) { + return false; + } + if (!isScheduledUpdateEnabled()) { + return false; + } + if (relatedEntitiesAggState.getLastRelatedEntitiesRefreshTs() == -1L) { + return true; + } + return relatedEntitiesAggState.getLastRelatedEntitiesRefreshTs() < System.currentTimeMillis() - scheduledUpdateIntervalMillis; + } + + @Override + public void close() { + try { + if (tbelExpressions != null) { + tbelExpressions.values().forEach(CalculatedFieldScriptEngine::destroy); + } + if (simpleExpressions != null) { + simpleExpressions.values().forEach(ThreadLocal::remove); + } + } catch (Exception e) { + log.warn("Failed to stop {}", this, e); + } + } + public String getSizeExceedsLimitMessage() { return "Failed to init CF state. State size exceeds limit of " + (maxStateSize / 1024) + "Kb!"; } + public boolean hasCurrentOwnerSourceArguments() { + return !dynamicEntityArguments.isEmpty(); + } + + @Override + public String toString() { + return "CalculatedFieldCtx{" + + "cfId=" + cfId + + ", cfType=" + cfType + + ", entityId=" + entityId + + '}'; + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java index 5f8e7538c4..c649ab5dbf 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java @@ -17,55 +17,63 @@ package org.thingsboard.server.service.cf.ctx.state; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.actors.TbActorRef; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.util.CollectionsUtil; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.service.cf.CalculatedFieldResult; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState; +import java.io.Closeable; import java.util.List; import java.util.Map; import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArgumentProto; -@JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.PROPERTY, - property = "type" -) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ - @JsonSubTypes.Type(value = SimpleCalculatedFieldState.class, name = "SIMPLE"), - @JsonSubTypes.Type(value = ScriptCalculatedFieldState.class, name = "SCRIPT"), - @JsonSubTypes.Type(value = GeofencingCalculatedFieldState.class, name = "GEOFENCING"), + @Type(value = SimpleCalculatedFieldState.class, name = "SIMPLE"), + @Type(value = ScriptCalculatedFieldState.class, name = "SCRIPT"), + @Type(value = GeofencingCalculatedFieldState.class, name = "GEOFENCING"), + @Type(value = AlarmCalculatedFieldState.class, name = "ALARM"), + @Type(value = PropagationCalculatedFieldState.class, name = "PROPAGATION"), + @Type(value = RelatedEntitiesAggregationCalculatedFieldState.class, name = "RELATED_ENTITIES_AGGREGATION") }) -public interface CalculatedFieldState { +public interface CalculatedFieldState extends Closeable { @JsonIgnore CalculatedFieldType getType(); + EntityId getEntityId(); + Map getArguments(); long getLatestTimestamp(); - default void setDirty(boolean dirty) { - } + void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx); - default boolean isDirty() { - return false; - } + void init(); - void setRequiredArguments(List requiredArguments); + Map update(Map arguments, CalculatedFieldCtx ctx); - boolean updateState(CalculatedFieldCtx ctx, Map argumentValues); + void reset(); - ListenableFuture performCalculation(EntityId entityId, CalculatedFieldCtx ctx); + ListenableFuture performCalculation(Map updatedArgs, CalculatedFieldCtx ctx) throws Exception; @JsonIgnore boolean isReady(); + ReadinessStatus getReadinessStatus(); + boolean isSizeExceedsLimit(); @JsonIgnore @@ -73,6 +81,10 @@ public interface CalculatedFieldState { return !isSizeExceedsLimit(); } + TopicPartitionInfo getPartition(); + + void setPartition(TopicPartitionInfo partition); + void checkStateSize(CalculatedFieldEntityCtxId ctxId, long maxStateSize); default void checkArgumentSize(String name, ArgumentEntry entry, CalculatedFieldCtx ctx) { @@ -86,4 +98,17 @@ public interface CalculatedFieldState { } } + record ReadinessStatus(boolean ready, String errorMsg) { + + private static final String ERROR_MESSAGE = "Required arguments are missing: "; + private static final ReadinessStatus READY = new ReadinessStatus(true, null); + + public static ReadinessStatus from(List emptyOrMissingArguments) { + if (CollectionsUtil.isEmpty(emptyOrMissingArguments)) { + return ReadinessStatus.READY; + } + return new ReadinessStatus(false, ERROR_MESSAGE + String.join(", ", emptyOrMissingArguments)); + } + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/KafkaCalculatedFieldStateService.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/KafkaCalculatedFieldStateService.java index 2b52892744..e8174bfd57 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/KafkaCalculatedFieldStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/KafkaCalculatedFieldStateService.java @@ -43,6 +43,7 @@ import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory; import org.thingsboard.server.service.cf.AbstractCalculatedFieldStateService; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import static org.thingsboard.server.queue.common.AbstractTbQueueTemplate.bytesToString; @@ -77,9 +78,9 @@ public class KafkaCalculatedFieldStateService extends AbstractCalculatedFieldSta for (TbProtoQueueMsg msg : msgs) { try { if (msg.getValue() != null) { - processRestoredState(msg.getValue()); + processRestoredState(msg.getValue(), consumerKey.partition()); } else { - processRestoredState(getStateId(msg.getHeaders()), null); + processRestoredState(getStateId(msg.getHeaders()), null, consumerKey.partition()); } } catch (Throwable t) { log.error("Failed to process state message: {}", msg, t); @@ -104,6 +105,11 @@ public class KafkaCalculatedFieldStateService extends AbstractCalculatedFieldSta this.stateProducer = (TbKafkaProducerTemplate>) queueFactory.createCalculatedFieldStateProducer(); } + @Override + public void restore(QueueKey queueKey, Set partitions) { + stateService.update(queueKey, partitions, null); + } + @Override protected void doPersist(CalculatedFieldEntityCtxId stateId, CalculatedFieldStateProto stateMsgProto, TbCallback callback) { TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, DataConstants.CF_STATES_QUEUE_NAME, stateId.tenantId(), stateId.entityId()); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/RocksDBCalculatedFieldStateService.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/RocksDBCalculatedFieldStateService.java index 9dc6139ca5..05bfb8b717 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/RocksDBCalculatedFieldStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/RocksDBCalculatedFieldStateService.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.service.cf.ctx.state; -import com.google.protobuf.InvalidProtocolBufferException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -64,8 +63,8 @@ public class RocksDBCalculatedFieldStateService extends AbstractCalculatedFieldS if (stateService.getPartitions().isEmpty()) { cfRocksDb.forEach((key, value) -> { try { - processRestoredState(CalculatedFieldStateProto.parseFrom(value)); - } catch (InvalidProtocolBufferException e) { + processRestoredState(CalculatedFieldStateProto.parseFrom(value), null); + } catch (Exception e) { log.error("[{}] Failed to process restored state", key, e); } }); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java index fe7dfa04d0..c52c01549f 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java @@ -15,67 +15,54 @@ */ package org.thingsboard.server.service.cf.ctx.state; -import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; -import lombok.Data; import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.thingsboard.script.api.tbel.TbelCfArg; -import org.thingsboard.script.api.tbel.TbelCfCtx; -import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.actors.TbActorRef; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.service.cf.CalculatedFieldResult; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; -@Data @Slf4j -@NoArgsConstructor @EqualsAndHashCode(callSuper = true) public class ScriptCalculatedFieldState extends BaseCalculatedFieldState { - public ScriptCalculatedFieldState(List requiredArguments) { - super(requiredArguments); + protected CalculatedFieldScriptEngine tbelExpression; + + public ScriptCalculatedFieldState(EntityId entityId) { + super(entityId); } @Override - public CalculatedFieldType getType() { - return CalculatedFieldType.SCRIPT; + public void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx) { + super.setCtx(ctx, actorCtx); + this.tbelExpression = ctx.getTbelExpressions().get(ctx.getExpression()); } @Override - public ListenableFuture performCalculation(EntityId entityId, CalculatedFieldCtx ctx) { - Map arguments = new LinkedHashMap<>(); - List args = new ArrayList<>(ctx.getArgNames().size() + 1); - args.add(new Object()); // first element is a ctx, but we will set it later; - for (String argName : ctx.getArgNames()) { - var arg = toTbelArgument(argName); - arguments.put(argName, arg); - if (arg instanceof TbelCfSingleValueArg svArg) { - args.add(svArg.getValue()); - } else { - args.add(arg); - } - } - args.set(0, new TbelCfCtx(arguments, getLatestTimestamp())); - ListenableFuture resultFuture = ctx.getCalculatedFieldScriptEngine().executeJsonAsync(args.toArray()); + public ListenableFuture performCalculation(Map updatedArgs, CalculatedFieldCtx ctx) { + ListenableFuture resultFuture = ctx.evaluateTbelExpression(tbelExpression, this); Output output = ctx.getOutput(); return Futures.transform(resultFuture, - result -> new CalculatedFieldResult(output.getType(), output.getScope(), result), + result -> TelemetryCalculatedFieldResult.builder() + .type(output.getType()) + .scope(output.getScope()) + .result(JacksonUtil.valueToTree(result)) + .build(), MoreExecutors.directExecutor() ); } - private TbelCfArg toTbelArgument(String key) { - return arguments.get(key).toTbelCfArg(); + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.SCRIPT; } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java index 80b650fc7c..ab0ed26dfe 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java @@ -19,77 +19,47 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.Data; import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; +import net.objecthunter.exp4j.Expression; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.tbel.TbUtils; +import org.thingsboard.server.actors.TbActorRef; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.kv.BasicKvEntry; import org.thingsboard.server.service.cf.CalculatedFieldResult; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; -import java.util.List; import java.util.Map; -@Data -@NoArgsConstructor @EqualsAndHashCode(callSuper = true) public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { - public SimpleCalculatedFieldState(List requiredArguments) { - super(requiredArguments); - } + private ThreadLocal expression; - @Override - public CalculatedFieldType getType() { - return CalculatedFieldType.SIMPLE; + public SimpleCalculatedFieldState(EntityId entityId) { + super(entityId); } @Override - protected void validateNewEntry(ArgumentEntry newEntry) { - if (newEntry instanceof TsRollingArgumentEntry) { - throw new IllegalArgumentException("Rolling argument entry is not supported for simple calculated fields."); - } + public void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx) { + super.setCtx(ctx, actorCtx); + this.expression = ctx.getSimpleExpressions().get(ctx.getExpression()); } @Override - public ListenableFuture performCalculation(EntityId entityId, CalculatedFieldCtx ctx) { - var expr = ctx.getCustomExpression().get(); - - for (Map.Entry entry : this.arguments.entrySet()) { - try { - BasicKvEntry kvEntry = ((SingleValueArgumentEntry) entry.getValue()).getKvEntryValue(); - double value = switch (kvEntry.getDataType()) { - case LONG -> kvEntry.getLongValue().map(Long::doubleValue).orElseThrow(); - case DOUBLE -> kvEntry.getDoubleValue().orElseThrow(); - case BOOLEAN -> kvEntry.getBooleanValue().map(b -> b ? 1.0 : 0.0).orElseThrow(); - case STRING, JSON -> Double.parseDouble(kvEntry.getValueAsString()); - }; - expr.setVariable(entry.getKey(), value); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Argument '" + entry.getKey() + "' is not a number."); - } - } - - double expressionResult = expr.evaluate(); + public ListenableFuture performCalculation(Map updatedArgs, CalculatedFieldCtx ctx) { + double expressionResult = ctx.evaluateSimpleExpression(expression.get(), this); Output output = ctx.getOutput(); - Object result = formatResult(expressionResult, output.getDecimalsByDefault()); + Object result = TbUtils.roundResult(expressionResult, output.getDecimalsByDefault()); JsonNode outputResult = createResultJson(ctx.isUseLatestTs(), output.getName(), result); - return Futures.immediateFuture(new CalculatedFieldResult(output.getType(), output.getScope(), outputResult)); - } - - private Object formatResult(double expressionResult, Integer decimals) { - if (decimals == null) { - return expressionResult; - } - if (decimals.equals(0)) { - return TbUtils.toInt(expressionResult); - } - return TbUtils.toFixed(expressionResult, decimals); + return Futures.immediateFuture(TelemetryCalculatedFieldResult.builder() + .type(output.getType()) + .scope(output.getScope()) + .result(outputResult) + .build()); } private JsonNode createResultJson(boolean useLatestTs, String outputName, Object result) { @@ -101,16 +71,20 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { } else { valuesNode.set(outputName, JacksonUtil.valueToTree(result)); } + return toSimpleResult(useLatestTs, valuesNode); + } - long latestTs = getLatestTimestamp(); - if (useLatestTs && latestTs != -1) { - ObjectNode resultNode = JacksonUtil.newObjectNode(); - resultNode.put("ts", latestTs); - resultNode.set("values", valuesNode); - return resultNode; - } else { - return valuesNode; + @Override + protected void validateNewEntry(String key, ArgumentEntry newEntry) { + if (newEntry instanceof TsRollingArgumentEntry) { + throw new IllegalArgumentException("Unsupported argument type detected for argument: " + key + ". " + + "Rolling argument entry is not supported for simple calculated fields."); } } + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.SIMPLE; + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java index 1ceea2c621..d8e4fcf5d4 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java @@ -20,9 +20,11 @@ import com.fasterxml.jackson.core.type.TypeReference; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import org.springframework.lang.Nullable; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.tbel.TbelCfArg; import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BasicKvEntry; import org.thingsboard.server.common.data.kv.JsonDataEntry; @@ -37,11 +39,35 @@ import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto; @AllArgsConstructor public class SingleValueArgumentEntry implements ArgumentEntry { - private long ts; - private BasicKvEntry kvEntryValue; - private Long version; + @Nullable + protected EntityId entityId; - private boolean forceResetPrevious; + protected long ts; + protected BasicKvEntry kvEntryValue; + protected Long version; + + protected boolean forceResetPrevious; + + public static final Long DEFAULT_VERSION = -1L; + + public SingleValueArgumentEntry(EntityId entityId, ArgumentEntry entry) { + this(entry); + this.entityId = entityId; + } + + public SingleValueArgumentEntry(ArgumentEntry entry) { + if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) { + this.ts = singleValueArgumentEntry.ts; + this.kvEntryValue = singleValueArgumentEntry.kvEntryValue; + this.version = singleValueArgumentEntry.version; + this.forceResetPrevious = singleValueArgumentEntry.forceResetPrevious; + } + } + + public SingleValueArgumentEntry(EntityId entityId, TsKvProto entry) { + this(entry); + this.entityId = entityId; + } public SingleValueArgumentEntry(TsKvProto entry) { this.ts = entry.getTs(); @@ -51,6 +77,11 @@ public class SingleValueArgumentEntry implements ArgumentEntry { this.kvEntryValue = ProtoUtils.fromProto(entry.getKv()); } + public SingleValueArgumentEntry(EntityId entityId, AttributeValueProto entry) { + this(entry); + this.entityId = entityId; + } + public SingleValueArgumentEntry(AttributeValueProto entry) { this.ts = entry.getLastUpdateTs(); if (entry.hasVersion()) { @@ -59,6 +90,11 @@ public class SingleValueArgumentEntry implements ArgumentEntry { this.kvEntryValue = ProtoUtils.basicKvEntryFromProto(entry); } + public SingleValueArgumentEntry(EntityId entityId, KvEntry entry) { + this(entry); + this.entityId = entityId; + } + public SingleValueArgumentEntry(KvEntry entry) { if (entry instanceof TsKvEntry tsKvEntry) { this.ts = tsKvEntry.getTs(); @@ -70,6 +106,11 @@ public class SingleValueArgumentEntry implements ArgumentEntry { this.kvEntryValue = ProtoUtils.basicKvEntryFromKvEntry(entry); } + public SingleValueArgumentEntry(EntityId entityId, long ts, BasicKvEntry kvEntryValue, Long version) { + this(ts, kvEntryValue, version); + this.entityId = entityId; + } + public SingleValueArgumentEntry(long ts, BasicKvEntry kvEntryValue, Long version) { this.ts = ts; this.kvEntryValue = kvEntryValue; @@ -93,6 +134,9 @@ public class SingleValueArgumentEntry implements ArgumentEntry { @Override public TbelCfArg toTbelCfArg() { + if (isEmpty()) { + return new TbelCfSingleValueArg(ts, null); + } Object value = kvEntryValue.getValue(); if (kvEntryValue instanceof JsonDataEntry) { try { @@ -112,8 +156,10 @@ public class SingleValueArgumentEntry implements ArgumentEntry { @Override public boolean updateEntry(ArgumentEntry entry) { if (entry instanceof SingleValueArgumentEntry singleValueEntry) { - if (singleValueEntry.getTs() <= this.ts) { - return false; + if (singleValueEntry.getTs() < this.ts) { + if (!isDefaultValue()) { + return false; + } } Long newVersion = singleValueEntry.getVersion(); @@ -128,4 +174,9 @@ public class SingleValueArgumentEntry implements ArgumentEntry { } return false; } + + public boolean isDefaultValue() { + return DEFAULT_VERSION.equals(this.version); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java new file mode 100644 index 0000000000..ff50cd99b6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java @@ -0,0 +1,241 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state.aggregation; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.actors.TbActorRef; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.service.cf.CalculatedFieldResult; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.cf.ctx.state.aggregation.function.AggEntry; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.ScheduledFuture; + +import static java.util.concurrent.TimeUnit.SECONDS; + +@Slf4j +@Getter +public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculatedFieldState { + + @Setter + private long lastArgsRefreshTs = -1; + @Setter + private long lastMetricsEvalTs = -1; + @Setter + private long lastRelatedEntitiesRefreshTs = -1; + private long deduplicationIntervalMs = -1; + private Map metrics; + + private ScheduledFuture reevaluationFuture; + + public RelatedEntitiesAggregationCalculatedFieldState(EntityId entityId) { + super(entityId); + } + + @Override + public void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx) { + super.setCtx(ctx, actorCtx); + var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); + metrics = configuration.getMetrics(); + deduplicationIntervalMs = SECONDS.toMillis(configuration.getDeduplicationIntervalInSec()); + } + + @Override + public void close() { + super.close(); + if (reevaluationFuture != null) { + reevaluationFuture.cancel(true); + reevaluationFuture = null; + } + } + + @Override + public void reset() { // must reset everything dependent on arguments + super.reset(); + lastArgsRefreshTs = -1; + lastMetricsEvalTs = -1; + lastRelatedEntitiesRefreshTs = -1; + metrics = null; + } + + public void updateLastRelatedEntitiesRefreshTs() { + lastRelatedEntitiesRefreshTs = System.currentTimeMillis(); + } + + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.RELATED_ENTITIES_AGGREGATION; + } + + @Override + public Map update(Map argumentValues, CalculatedFieldCtx ctx) { + lastArgsRefreshTs = System.currentTimeMillis(); + return super.update(argumentValues, ctx); + } + + public List checkRelatedEntities(List relatedEntities) { + Map> entityInputs = prepareInputs(); + findOutdatedEntities(entityInputs, relatedEntities).forEach(this::cleanupEntityData); + updateLastRelatedEntitiesRefreshTs(); + return findMissingEntities(entityInputs, relatedEntities); + } + + private List findMissingEntities(Map> entityInputs, List relatedEntities) { + List missing = new ArrayList<>(); + relatedEntities.forEach(entityId -> { + if (!entityInputs.containsKey(entityId)) { + missing.add(entityId); + log.warn("[{}] Missing related entity inputs for {}", ctx.getCfId(), entityId); + } + }); + return missing; + } + + private List findOutdatedEntities(Map> entityInputs, List relatedEntities) { + List outdated = new ArrayList<>(); + entityInputs.keySet().forEach(entityId -> { + if (!relatedEntities.contains(entityId)) { + outdated.add(entityId); + log.warn("[{}] CF state keeps outdated related entity {}", ctx.getCfId(), entityId); + } + }); + return outdated; + } + + public Map updateEntityData(Map fetchedArgs) { + lastMetricsEvalTs = -1; + return update(fetchedArgs, ctx); + } + + public void cleanupEntityData(EntityId relatedEntityId) { + arguments.values().forEach(argEntry -> { + RelatedEntitiesArgumentEntry aggEntry = (RelatedEntitiesArgumentEntry) argEntry; + aggEntry.getEntityInputs().remove(relatedEntityId); + }); + lastMetricsEvalTs = -1; + lastArgsRefreshTs = System.currentTimeMillis(); + } + + public void scheduleReevaluation() { + ScheduledFuture future = ctx.scheduleReevaluation(deduplicationIntervalMs, actorCtx); + if (future != null) { + reevaluationFuture = future; + } + } + + @Override + public ListenableFuture performCalculation(Map updatedArgs, CalculatedFieldCtx ctx) throws Exception { + boolean cfUpdated = updatedArgs != null && updatedArgs.isEmpty(); + if (shouldRecalculate() || cfUpdated) { + Output output = ctx.getOutput(); + ObjectNode aggResult = aggregateMetrics(output); + lastMetricsEvalTs = System.currentTimeMillis(); + scheduleReevaluation(); + return Futures.immediateFuture(TelemetryCalculatedFieldResult.builder() + .type(output.getType()) + .scope(output.getScope()) + .result(toSimpleResult(ctx.isUseLatestTs(), aggResult)) + .build()); + } else { + return Futures.immediateFuture(TelemetryCalculatedFieldResult.EMPTY); + } + } + + private boolean shouldRecalculate() { + boolean intervalPassed = lastMetricsEvalTs <= System.currentTimeMillis() - deduplicationIntervalMs; + boolean argsUpdatedDuringInterval = lastArgsRefreshTs > lastMetricsEvalTs; + return intervalPassed && argsUpdatedDuringInterval; + } + + private Map> prepareInputs() { + Map> inputs = new HashMap<>(); + for (Map.Entry argEntry : arguments.entrySet()) { + String key = argEntry.getKey(); + RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry = (RelatedEntitiesArgumentEntry) argEntry.getValue(); + relatedEntitiesArgumentEntry.getEntityInputs().forEach((entityId, argumentEntry) -> { + inputs.computeIfAbsent(entityId, k -> new HashMap<>()).put(key, argumentEntry); + }); + } + return inputs; + } + + private ObjectNode aggregateMetrics(Output output) throws Exception { + ObjectNode aggResult = JacksonUtil.newObjectNode(); + Map> inputs = prepareInputs(); + for (Entry entry : metrics.entrySet()) { + String metricKey = entry.getKey(); + AggMetric metric = entry.getValue(); + + AggEntry aggMetricEntry = AggEntry.createAggFunction(metric.getFunction()); + aggregateMetric(metric, aggMetricEntry, inputs); + aggMetricEntry.result(output.getDecimalsByDefault()).ifPresent(result -> { + aggResult.set(metricKey, JacksonUtil.valueToTree(result)); + }); + } + return aggResult; + } + + private void aggregateMetric(AggMetric metric, AggEntry aggEntry, Map> inputs) throws Exception { + for (Map entityInputs : inputs.values()) { + if (applyAggregation(metric.getFilter(), entityInputs)) { + Object arg = resolveAggregationInput(metric.getInput(), entityInputs); + if (arg != null) { + aggEntry.update(arg); + } + } + } + } + + private boolean applyAggregation(String filter, Map entityInputs) throws Exception { + if (filter == null || filter.isEmpty()) { + return true; + } else { + Object filterResult = ctx.evaluateTbelExpression(filter, entityInputs, getLatestTimestamp()).get(); + return filterResult instanceof Boolean booleanResult && booleanResult; + } + } + + private Object resolveAggregationInput(AggInput aggInput, Map entityInputs) throws Exception { + if (aggInput instanceof AggFunctionInput functionInput) { + return ctx.evaluateTbelExpression(functionInput.getFunction(), entityInputs, getLatestTimestamp()).get(); + } else { + String inputKey = ((AggKeyInput) aggInput).getKey(); + return entityInputs.get(inputKey).getValue(); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesArgumentEntry.java new file mode 100644 index 0000000000..2abe78d243 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesArgumentEntry.java @@ -0,0 +1,86 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state.aggregation; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfRelatedEntitiesArgumentValue; +import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType; +import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; + +import java.util.Map; +import java.util.stream.Collectors; + +@Data +@AllArgsConstructor +public class RelatedEntitiesArgumentEntry implements ArgumentEntry { + + private final Map entityInputs; + + private boolean forceResetPrevious; + + @Override + public ArgumentEntryType getType() { + return ArgumentEntryType.RELATED_ENTITIES; + } + + @Override + public Object getValue() { + return entityInputs; + } + + @Override + public boolean updateEntry(ArgumentEntry entry) { + if (entry instanceof RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry) { + entityInputs.putAll(relatedEntitiesArgumentEntry.entityInputs); + return true; + } else if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) { + if (entry.isForceResetPrevious()) { + entityInputs.put(singleValueArgumentEntry.getEntityId(), singleValueArgumentEntry); + return true; + } + ArgumentEntry argumentEntry = entityInputs.get(singleValueArgumentEntry.getEntityId()); + if (argumentEntry != null) { + argumentEntry.updateEntry(singleValueArgumentEntry); + } else { + entityInputs.put(singleValueArgumentEntry.getEntityId(), singleValueArgumentEntry); + } + return true; + } else { + throw new IllegalArgumentException("Unsupported argument entry type for aggregation argument entry: " + entry.getType()); + } + } + + @Override + public boolean isEmpty() { + return entityInputs.isEmpty(); + } + + @Override + public TbelCfArg toTbelCfArg() { + var inputs = entityInputs.entrySet().stream() + .collect(Collectors.toMap( + e -> e.getKey().getId(), + e -> (TbelCfSingleValueArg) e.getValue().toTbelCfArg() + )); + return new TbelCfRelatedEntitiesArgumentValue(inputs); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/AggEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/AggEntry.java new file mode 100644 index 0000000000..c4b93fd91d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/AggEntry.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state.aggregation.function; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; + +import java.util.Optional; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = AvgAggEntry.class, name = "AVG"), + @JsonSubTypes.Type(value = CountAggEntry.class, name = "COUNT"), + @JsonSubTypes.Type(value = CountUniqueAggEntry.class, name = "COUNT_UNIQUE"), + @JsonSubTypes.Type(value = MaxAggEntry.class, name = "MAX"), + @JsonSubTypes.Type(value = MinAggEntry.class, name = "MIN"), + @JsonSubTypes.Type(value = SumAggEntry.class, name = "SUM") +}) +public interface AggEntry { + + @JsonIgnore + AggFunction getType(); + + void update(Object value); + + Optional result(Integer precision); + + static AggEntry createAggFunction(AggFunction function) { + return switch (function) { + case MIN -> new MinAggEntry(); + case MAX -> new MaxAggEntry(); + case SUM -> new SumAggEntry(); + case AVG -> new AvgAggEntry(); + case COUNT -> new CountAggEntry(); + case COUNT_UNIQUE -> new CountUniqueAggEntry(); + }; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/AvgAggEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/AvgAggEntry.java new file mode 100644 index 0000000000..e063ff2ea2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/AvgAggEntry.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state.aggregation.function; + +import org.thingsboard.script.api.tbel.TbUtils; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +public class AvgAggEntry extends BaseAggEntry { + + private BigDecimal sum = BigDecimal.ZERO; + private long count = 0L; + + @Override + protected void doUpdate(double value) { + if (value != 0.0) { + sum = sum.add(BigDecimal.valueOf(value)); + } + this.count++; + } + + @Override + protected Object prepareResult(Integer precision) { + double result = sum.divide(BigDecimal.valueOf(count), RoundingMode.HALF_UP).doubleValue(); + return TbUtils.roundResult(result, precision); + } + + @Override + public AggFunction getType() { + return AggFunction.AVG; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/BaseAggEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/BaseAggEntry.java new file mode 100644 index 0000000000..8ca523938d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/BaseAggEntry.java @@ -0,0 +1,55 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state.aggregation.function; + +import java.util.Optional; + +public abstract class BaseAggEntry implements AggEntry { + + private boolean hasResult = false; + + @Override + public void update(Object value) { + doUpdate(extractDoubleValue(value)); + hasResult = true; + } + + @Override + public Optional result(Integer precision) { + if (hasResult) { + hasResult = false; + return Optional.of(prepareResult(precision)); + } else { + return Optional.empty(); + } + } + + protected abstract void doUpdate(double value); + + protected abstract Object prepareResult(Integer precision); + + protected double extractDoubleValue(Object value) { + try { + if (value instanceof Number number) { + return number.doubleValue(); + } + return Double.parseDouble(value.toString()); + } catch (Exception e) { + throw new NumberFormatException("Cannot parse value " + value.toString()); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/CountAggEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/CountAggEntry.java new file mode 100644 index 0000000000..09116985d2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/CountAggEntry.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state.aggregation.function; + +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; + +import java.util.Optional; + +public class CountAggEntry implements AggEntry { + + private long count = 0L; + + @Override + public void update(Object value) { + count++; + } + + @Override + public Optional result(Integer precision) { + return Optional.of(count); + } + + @Override + public AggFunction getType() { + return AggFunction.COUNT; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/CountUniqueAggEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/CountUniqueAggEntry.java new file mode 100644 index 0000000000..3c14d7c9b9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/CountUniqueAggEntry.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state.aggregation.function; + +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; + +import java.util.Optional; +import java.util.Set; + +public class CountUniqueAggEntry implements AggEntry { + + private Set items; + + @Override + public void update(Object value) { + if (value != null) { + items.add(String.valueOf(value)); + } + } + + @Override + public Optional result(Integer precision) { + return Optional.of(items.size()); + } + + @Override + public AggFunction getType() { + return AggFunction.COUNT_UNIQUE; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/MaxAggEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/MaxAggEntry.java new file mode 100644 index 0000000000..6d734a5a08 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/MaxAggEntry.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state.aggregation.function; + +import org.thingsboard.script.api.tbel.TbUtils; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; + +public class MaxAggEntry extends BaseAggEntry { + + private double max = Double.MIN_VALUE; + + @Override + protected void doUpdate(double value) { + if (value > max) { + max = value; + } + } + + @Override + protected Object prepareResult(Integer precision) { + return TbUtils.roundResult(max, precision); + } + + @Override + public AggFunction getType() { + return AggFunction.MAX; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/MinAggEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/MinAggEntry.java new file mode 100644 index 0000000000..e517ad305f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/MinAggEntry.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state.aggregation.function; + +import org.thingsboard.script.api.tbel.TbUtils; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; + +public class MinAggEntry extends BaseAggEntry { + + private double min = Double.MAX_VALUE; + + @Override + protected void doUpdate(double value) { + if (value < min) { + min = value; + } + } + + @Override + protected Object prepareResult(Integer precision) { + return TbUtils.roundResult(min, precision); + } + + @Override + public AggFunction getType() { + return AggFunction.MIN; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/SumAggEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/SumAggEntry.java new file mode 100644 index 0000000000..fe29d27b7e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/SumAggEntry.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state.aggregation.function; + +import org.thingsboard.script.api.tbel.TbUtils; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; + +import java.math.BigDecimal; + +public class SumAggEntry extends BaseAggEntry { + + private BigDecimal sum = BigDecimal.ZERO; + + @Override + protected void doUpdate(double value) { + if (value != 0.0) { + sum = sum.add(BigDecimal.valueOf(value)); + } + } + + @Override + protected Object prepareResult(Integer precision) { + return TbUtils.roundResult(sum.doubleValue(), precision); + } + + @Override + public AggFunction getType() { + return AggFunction.SUM; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java new file mode 100644 index 0000000000..b96fde9898 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java @@ -0,0 +1,552 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state.alarm; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.KvUtil; +import org.thingsboard.rule.engine.action.TbAlarmResult; +import org.thingsboard.server.actors.TbActorRef; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.alarm.AlarmApiCallResult; +import org.thingsboard.server.common.data.alarm.AlarmCreateOrUpdateActiveRequest; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.alarm.AlarmUpdateRequest; +import org.thingsboard.server.common.data.alarm.rule.AlarmRule; +import org.thingsboard.server.common.data.alarm.rule.condition.AlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionType; +import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.ComplexFilterPredicate; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.KeyFilterPredicate; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.StringFilterPredicate; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.service.cf.AlarmCalculatedFieldResult; +import org.thingsboard.server.service.cf.CalculatedFieldResult; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; + +import java.util.Comparator; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; + +import static org.thingsboard.server.common.data.StringUtils.equalsAny; +import static org.thingsboard.server.common.data.StringUtils.splitByCommaWithoutQuotes; +import static org.thingsboard.server.service.cf.ctx.state.alarm.AlarmEvalResult.Status.FALSE; +import static org.thingsboard.server.service.cf.ctx.state.alarm.AlarmEvalResult.Status.NOT_YET_TRUE; +import static org.thingsboard.server.service.cf.ctx.state.alarm.AlarmEvalResult.Status.TRUE; + +@EqualsAndHashCode(callSuper = true) +@Slf4j +public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { + + private AlarmCalculatedFieldConfiguration configuration; + private String alarmType; + + @Getter + private final Map createRuleStates = new TreeMap<>(Comparator.comparing(Enum::ordinal)); + @Getter + @Setter + private AlarmRuleState clearRuleState; + + @Getter + private Alarm currentAlarm; + private boolean initialFetchDone; + + // TODO: deprecate device profile node, describe the differences and improvements + + public AlarmCalculatedFieldState(EntityId entityId) { + super(entityId); + } + + @Override + public void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx) { + super.setCtx(ctx, actorCtx); + this.configuration = getConfiguration(ctx); + this.alarmType = ctx.getCalculatedField().getName(); + + Map createRules = configuration.getCreateRules(); + createRules.forEach((severity, rule) -> { + AlarmRuleState ruleState = createRuleStates.get(severity); + if (ruleState != null) { + ruleState.setAlarmRule(rule); + } + }); + AlarmRule clearRule = configuration.getClearRule(); + if (clearRule != null && clearRuleState != null) { + clearRuleState.setAlarmRule(clearRule); + } + + if (currentAlarm != null && !currentAlarm.getType().equals(alarmType)) { + currentAlarm = null; + initialFetchDone = false; + } + } + + @Override + public void init() { + super.init(); + AtomicBoolean reevalNeeded = new AtomicBoolean(false); + Map createRules = configuration.getCreateRules(); + for (AlarmSeverity severity : AlarmSeverity.values()) { + AlarmRule rule = createRules.get(severity); + if (rule != null) { + createRuleStates.compute(severity, (__, ruleState) -> { + return initRuleState(severity, rule, ruleState, reevalNeeded); + }); + } else { + AlarmRuleState state = createRuleStates.remove(severity); + if (state != null) { + clearState(state); + } + } + } + + AlarmRule clearRule = configuration.getClearRule(); + if (clearRule != null) { + clearRuleState = initRuleState(null, clearRule, clearRuleState, reevalNeeded); + } else { + if (clearRuleState != null) { + clearState(clearRuleState); + clearRuleState = null; + } + } + log.debug("Initialized create rule states {} and clear rule state {} for {}", createRuleStates, clearRuleState, configuration); + + if (reevalNeeded.get()) { + initCurrentAlarm(ctx); + createOrClearAlarms(state -> { + if (state.getCondition().getType() == AlarmConditionType.DURATION) { + AlarmEvalResult evalResult = state.reeval(System.currentTimeMillis(), ctx); + if (evalResult.getStatus() == TRUE || evalResult.getStatus() == NOT_YET_TRUE) { + ScheduledFuture future = ctx.scheduleReevaluation(evalResult.getLeftDuration(), actorCtx); + if (future != null) { + state.setDurationCheckFuture(future); + } + } + } + return AlarmEvalResult.NOT_YET_TRUE; + }, ctx); + } + } + + private AlarmRuleState initRuleState(AlarmSeverity severity, AlarmRule rule, AlarmRuleState ruleState, AtomicBoolean reevalNeeded) { + if (ruleState == null) { + ruleState = new AlarmRuleState(severity, rule, this); + } else { + // when restored + ruleState.setAlarmRule(rule); + ruleState.setActive(null); + AlarmCondition condition = rule.getCondition(); + if (condition.hasSchedule() || (condition.getType() == AlarmConditionType.DURATION && !ruleState.isEmpty())) { + reevalNeeded.set(true); + } + } + return ruleState; + } + + @Override + public void reset() { + super.reset(); + configuration = null; + } + + @Override + public void close() { + super.close(); + for (AlarmRuleState state : createRuleStates.values()) { + clearState(state); + } + clearState(clearRuleState); + } + + @Override + public ListenableFuture performCalculation(Map updatedArgs, CalculatedFieldCtx ctx) { + initCurrentAlarm(ctx); + TbAlarmResult result = createOrClearAlarms(state -> { + if (updatedArgs != null) { + boolean newEvent = !updatedArgs.isEmpty(); + AlarmEvalResult evalResult = state.eval(newEvent, ctx); + if (evalResult.getStatus() == NOT_YET_TRUE && evalResult.getLeftDuration() > 0) { + long leftDuration = evalResult.getLeftDuration(); + ScheduledFuture future = ctx.scheduleReevaluation(leftDuration, actorCtx); + if (future != null) { + state.setDurationCheckFuture(future); + } + } + return evalResult; + } else { + return state.reeval(System.currentTimeMillis(), ctx); + } + }, ctx); + return Futures.immediateFuture(AlarmCalculatedFieldResult.builder() + .alarmResult(result) + .build()); + } + + public void processAlarmAction(Alarm alarm, ActionType action) { + switch (action) { + case ALARM_ACK -> processAlarmAck(alarm); + case ALARM_CLEAR -> processAlarmClear(alarm); + case ALARM_DELETE -> processAlarmDelete(alarm); + } + } + + private void processAlarmClear(Alarm alarm) { + currentAlarm = null; + createRuleStates.values().forEach(this::clearState); + clearState(clearRuleState); + } + + private void processAlarmAck(Alarm alarm) { + currentAlarm.setAcknowledged(alarm.isAcknowledged()); + currentAlarm.setAckTs(alarm.getAckTs()); + } + + private void processAlarmDelete(Alarm alarm) { + processAlarmClear(alarm); + } + + private TbAlarmResult createOrClearAlarms(Function evalFunction, + CalculatedFieldCtx ctx) { + TbAlarmResult result = null; + AlarmRuleState resultState = null; + AlarmRuleState.StateInfo resultStateInfo = null; + + for (AlarmRuleState state : createRuleStates.values()) { + AlarmEvalResult evalResult = evalFunction.apply(state); + log.debug("Evaluated create rule {} with args {}. Result: {}", state, arguments, evalResult); + if (evalResult.getStatus() == TRUE) { + resultState = state; + break; + } else if (evalResult.getStatus() == FALSE) { + clearState(state); + } + } + + if (resultState != null) { + result = calculateAlarmResult(resultState, ctx); + resultStateInfo = resultState.getStateInfo(); + log.debug("Alarm result for state {}: {}", resultState, result); + clearState(clearRuleState); + } else if (currentAlarm != null && clearRuleState != null) { + AlarmEvalResult evalResult = evalFunction.apply(clearRuleState); + log.debug("Evaluated clear rule {} with args {}. Result: {}", clearRuleState, arguments, evalResult); + if (evalResult.getStatus() == TRUE) { + resultStateInfo = clearRuleState.getStateInfo(); + clearState(clearRuleState); + for (AlarmRuleState state : createRuleStates.values()) { + clearState(state); + } + AlarmApiCallResult clearResult = ctx.getAlarmService().clearAlarm( + ctx.getTenantId(), currentAlarm.getId(), System.currentTimeMillis(), createDetails(clearRuleState), false + ); + if (clearResult.isCleared()) { + result = TbAlarmResult.builder() + .isCleared(true) + .alarm(clearResult.getAlarm()) + .build(); + resultState = clearRuleState; + } + currentAlarm = null; + } else if (evalResult.getStatus() == FALSE) { + clearState(clearRuleState); + } + } + if (result != null && resultState != null) { + result.setConditionRepeats(resultStateInfo.eventCount()); + result.setConditionDuration(resultStateInfo.duration()); + } + return result; + } + + private void clearState(AlarmRuleState state) { + if (state != null) { + log.debug("Clearing rule state {}", state); + state.clear(); + } + } + + private void initCurrentAlarm(CalculatedFieldCtx ctx) { + if (!initialFetchDone) { + Alarm alarm = ctx.getAlarmService().findLatestActiveByOriginatorAndType(ctx.getTenantId(), entityId, alarmType); + if (alarm != null && !alarm.getStatus().isCleared()) { + currentAlarm = alarm; + } + initialFetchDone = true; + } + } + + private TbAlarmResult calculateAlarmResult(AlarmRuleState ruleState, CalculatedFieldCtx ctx) { + AlarmSeverity severity = ruleState.getSeverity(); + if (currentAlarm != null) { + currentAlarm.setEndTs(System.currentTimeMillis()); + AlarmSeverity oldSeverity = currentAlarm.getSeverity(); + // Skip update if severity is decreased. + if (severity.ordinal() <= oldSeverity.ordinal()) { + currentAlarm.setDetails(createDetails(ruleState)); + currentAlarm.setSeverity(severity); + AlarmApiCallResult result = ctx.getAlarmService().updateAlarm(AlarmUpdateRequest.fromAlarm(currentAlarm)); + currentAlarm = result.getAlarm(); + return TbAlarmResult.fromAlarmResult(result); + } else { + return null; + } + } else { + var newAlarm = new Alarm(); + newAlarm.setType(alarmType); + newAlarm.setAcknowledged(false); + newAlarm.setCleared(false); + newAlarm.setSeverity(severity); + long startTs = latestTimestamp; + long currentTime = System.currentTimeMillis(); + if (startTs == 0L || startTs > currentTime) { + startTs = currentTime; + } + newAlarm.setStartTs(startTs); + newAlarm.setEndTs(startTs); + newAlarm.setDetails(createDetails(ruleState)); + newAlarm.setOriginator(entityId); + newAlarm.setTenantId(ctx.getTenantId()); + newAlarm.setPropagate(configuration.isPropagate()); + newAlarm.setPropagateToOwner(configuration.isPropagateToOwner()); + newAlarm.setPropagateToTenant(configuration.isPropagateToTenant()); + if (configuration.getPropagateRelationTypes() != null) { + newAlarm.setPropagateRelationTypes(configuration.getPropagateRelationTypes()); + } + AlarmApiCallResult result = ctx.getAlarmService().createAlarm(AlarmCreateOrUpdateActiveRequest.fromAlarm(newAlarm)); + currentAlarm = result.getAlarm(); + return TbAlarmResult.fromAlarmResult(result); + } + } + + private JsonNode createDetails(AlarmRuleState ruleState) { + JsonNode alarmDetails; + String alarmDetailsStr = ruleState.getAlarmRule().getAlarmDetails(); + DashboardId dashboardId = ruleState.getAlarmRule().getDashboardId(); + + if (StringUtils.isNotEmpty(alarmDetailsStr) || dashboardId != null) { + ObjectNode newDetails = JacksonUtil.newObjectNode(); + if (StringUtils.isNotEmpty(alarmDetailsStr)) { + for (Map.Entry entry : arguments.entrySet()) { + String key = entry.getKey(); + ArgumentEntry value = entry.getValue(); + alarmDetailsStr = alarmDetailsStr.replaceAll(String.format("\\$\\{%s}", key), String.valueOf(value.getValue())); + } + newDetails.put("data", alarmDetailsStr); + } + if (dashboardId != null) { + newDetails.put("dashboardId", dashboardId.getId().toString()); + } + alarmDetails = newDetails; + } else if (currentAlarm != null) { + alarmDetails = currentAlarm.getDetails(); + } else { + alarmDetails = JacksonUtil.newObjectNode(); + } + + return alarmDetails; + } + + @SneakyThrows + public boolean eval(AlarmConditionExpression expression, CalculatedFieldCtx ctx) { + if (expression instanceof TbelAlarmConditionExpression tbelExpression) { + Object result = ctx.evaluateTbelExpression(tbelExpression.getExpression(), this).get(); + if (result instanceof Boolean booleanResult) { + return booleanResult; + } else { + throw new IllegalStateException("Condition expression returned non-boolean value: '" + result + "'"); + } + } else { + SimpleAlarmConditionExpression simpleExpression = (SimpleAlarmConditionExpression) expression; + ComplexOperation operation = simpleExpression.getOperation(); + if (operation == null) { + operation = ComplexOperation.AND; + } + return switch (operation) { + case AND -> simpleExpression.getFilters().stream() + .allMatch(filter -> eval(getArgument(filter.getArgument()), filter)); + case OR -> simpleExpression.getFilters().stream() + .anyMatch(filter -> eval(getArgument(filter.getArgument()), filter)); + }; + } + } + + private boolean eval(SingleValueArgumentEntry argument, AlarmConditionFilter filter) { + ComplexOperation operation = filter.getOperation(); + if (operation == null) { + operation = ComplexOperation.AND; + } + return switch (operation) { + case AND -> filter.getPredicates().stream() + .allMatch(predicate -> eval(argument, predicate)); + case OR -> filter.getPredicates().stream() + .anyMatch(predicate -> eval(argument, predicate)); + }; + } + + private boolean eval(SingleValueArgumentEntry argument, KeyFilterPredicate predicate) { + return switch (predicate.getType()) { + case STRING -> evalStrPredicate(argument, (StringFilterPredicate) predicate); + case NUMERIC -> evalNumPredicate(argument, (NumericFilterPredicate) predicate); + case BOOLEAN -> evalBooleanPredicate(argument, (BooleanFilterPredicate) predicate); + case COMPLEX -> evalComplexPredicate(argument, (ComplexFilterPredicate) predicate); + }; + } + + private boolean evalComplexPredicate(SingleValueArgumentEntry argument, ComplexFilterPredicate complexPredicate) { + return switch (complexPredicate.getOperation()) { + case OR -> { + for (KeyFilterPredicate predicate : complexPredicate.getPredicates()) { + if (eval(argument, predicate)) { + yield true; + } + } + yield false; + } + case AND -> { + for (KeyFilterPredicate predicate : complexPredicate.getPredicates()) { + if (!eval(argument, predicate)) { + yield false; + } + } + yield true; + } + }; + } + + private boolean evalBooleanPredicate(SingleValueArgumentEntry argument, BooleanFilterPredicate predicate) { + Boolean value = KvUtil.getBoolValue(argument.getKvEntryValue()); + if (value == null) { + return false; + } + Boolean predicateValue = resolveValue(predicate.getValue(), KvUtil::getBoolValue); + if (predicateValue == null) { + return false; + } + return switch (predicate.getOperation()) { + case EQUAL -> value.equals(predicateValue); + case NOT_EQUAL -> !value.equals(predicateValue); + }; + } + + private boolean evalNumPredicate(SingleValueArgumentEntry argument, NumericFilterPredicate predicate) { + Double value = KvUtil.getDoubleValue(argument.getKvEntryValue()); + if (value == null) { + return false; + } + Double predicateValue = resolveValue(predicate.getValue(), KvUtil::getDoubleValue); + if (predicateValue == null) { + return false; + } + return switch (predicate.getOperation()) { + case NOT_EQUAL -> !value.equals(predicateValue); + case EQUAL -> value.equals(predicateValue); + case GREATER -> value > predicateValue; + case GREATER_OR_EQUAL -> value >= predicateValue; + case LESS -> value < predicateValue; + case LESS_OR_EQUAL -> value <= predicateValue; + }; + } + + private boolean evalStrPredicate(SingleValueArgumentEntry argument, StringFilterPredicate predicate) { + String value = KvUtil.getStringValue(argument.getKvEntryValue()); + if (value == null) { + return false; + } + String predicateValue = resolveValue(predicate.getValue(), KvUtil::getStringValue); + if (predicateValue == null) { + return false; + } + if (predicate.isIgnoreCase()) { + value = value.toLowerCase(); + predicateValue = predicateValue.toLowerCase(); + } + return switch (predicate.getOperation()) { + case CONTAINS -> value.contains(predicateValue); + case EQUAL -> value.equals(predicateValue); + case STARTS_WITH -> value.startsWith(predicateValue); + case ENDS_WITH -> value.endsWith(predicateValue); + case NOT_EQUAL -> !value.equals(predicateValue); + case NOT_CONTAINS -> !value.contains(predicateValue); + case IN -> equalsAny(value, splitByCommaWithoutQuotes(predicateValue)); + case NOT_IN -> !equalsAny(value, splitByCommaWithoutQuotes(predicateValue)); + }; + } + + protected T resolveValue(AlarmConditionValue conditionValue, Function mapper) { + T value = conditionValue.getStaticValue(); + if (value == null) { + String argument = conditionValue.getDynamicValueArgument(); + SingleValueArgumentEntry entry = getArgument(argument); + value = mapper.apply(entry.getKvEntryValue()); + if (value == null) { + throw new IllegalArgumentException("No proper value found for argument " + argument); + } + } + return value; + } + + protected SingleValueArgumentEntry getArgument(String key) { + SingleValueArgumentEntry entry = (SingleValueArgumentEntry) arguments.get(key); + if (entry == null) { + throw new IllegalArgumentException("Argument '" + key + "' is missing"); + } + return entry; + } + + private AlarmCalculatedFieldConfiguration getConfiguration(CalculatedFieldCtx ctx) { + return (AlarmCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); + } + + @Override + protected void validateNewEntry(String key, ArgumentEntry newEntry) { + if (!(newEntry instanceof SingleValueArgumentEntry)) { + throw new IllegalArgumentException("Only single value arguments supported"); + } + } + + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.ALARM; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmEvalResult.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmEvalResult.java new file mode 100644 index 0000000000..424a977c75 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmEvalResult.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state.alarm; + +import lombok.Data; +import lombok.RequiredArgsConstructor; + +@Data +@RequiredArgsConstructor +public class AlarmEvalResult { + + public static final AlarmEvalResult TRUE = new AlarmEvalResult(Status.TRUE); + public static final AlarmEvalResult FALSE = new AlarmEvalResult(Status.FALSE); + public static final AlarmEvalResult NOT_YET_TRUE = new AlarmEvalResult(Status.NOT_YET_TRUE); + + private final Status status; + private final long leftDuration; + private final long leftEvents; + + public AlarmEvalResult(Status status) { + this(status, 0, 0); + } + + public static AlarmEvalResult notYetTrue(long leftEvents, long leftDuration) { + return new AlarmEvalResult(Status.NOT_YET_TRUE, leftDuration, leftEvents); + } + + public enum Status { + FALSE, NOT_YET_TRUE, TRUE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java new file mode 100644 index 0000000000..8612607dfb --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java @@ -0,0 +1,344 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state.alarm; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.KvUtil; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.alarm.rule.AlarmRule; +import org.thingsboard.server.common.data.alarm.rule.condition.AlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionType; +import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; +import org.thingsboard.server.common.data.alarm.rule.condition.DurationAlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.RepeatingAlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; +import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AlarmSchedule; +import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AlarmScheduleType; +import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AnyTimeSchedule; +import org.thingsboard.server.common.data.alarm.rule.condition.schedule.CustomTimeSchedule; +import org.thingsboard.server.common.data.alarm.rule.condition.schedule.CustomTimeScheduleItem; +import org.thingsboard.server.common.data.alarm.rule.condition.schedule.SpecificTimeSchedule; +import org.thingsboard.server.common.msg.tools.SchedulerUtils; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Optional; +import java.util.concurrent.ScheduledFuture; + +@Data +@Slf4j +public class AlarmRuleState { + + private final AlarmSeverity severity; + private AlarmRule alarmRule; + private AlarmCalculatedFieldState state; + + private AlarmCondition condition; + + private long eventCount; + private long firstEventTs; // when duration condition started + private long lastEventTs; + private transient long duration; + private ScheduledFuture durationCheckFuture; + private Boolean active; + + public AlarmRuleState(AlarmSeverity severity, AlarmRule alarmRule, AlarmCalculatedFieldState state) { + this.severity = severity; + if (alarmRule != null) { + setAlarmRule(alarmRule); + } + this.state = state; + } + + public AlarmEvalResult eval(boolean newEvent, CalculatedFieldCtx ctx) { // on event or config change + long ts = newEvent ? state.getLatestTimestamp() : System.currentTimeMillis(); + active = isActive(ts); + if (!active) { + return AlarmEvalResult.FALSE; + } + return doEval(newEvent, ctx); + } + + public AlarmEvalResult reeval(long ts, CalculatedFieldCtx ctx) { // on scheduled duration check or periodic re-eval for rules with schedule + boolean active = isActive(ts); + switch (condition.getType()) { + case SIMPLE, REPEATING -> { + if (this.active == null || active != this.active) { + this.active = active; + if (active) { + return doEval(false, ctx); + } + } + if (active) { + return AlarmEvalResult.NOT_YET_TRUE; + } else { + return AlarmEvalResult.FALSE; + } + } + case DURATION -> { + if (!active) { + return AlarmEvalResult.FALSE; + } + long requiredDuration = getRequiredDurationInMs(); + if (requiredDuration > 0 && lastEventTs > 0 && ts > lastEventTs) { + duration = ts - firstEventTs; + long leftDuration = requiredDuration - duration; + if (leftDuration <= 0) { + return AlarmEvalResult.TRUE; + } else { + return AlarmEvalResult.notYetTrue(0, leftDuration); + } + } + } + } + return AlarmEvalResult.FALSE; + } + + public AlarmEvalResult doEval(boolean newEvent, CalculatedFieldCtx ctx) { + return switch (condition.getType()) { + case SIMPLE -> evalSimple(ctx); + case DURATION -> evalDuration(ctx); + case REPEATING -> evalRepeating(newEvent, ctx); + }; + } + + private AlarmEvalResult evalSimple(CalculatedFieldCtx ctx) { + return eval(condition.getExpression(), ctx) ? AlarmEvalResult.TRUE : AlarmEvalResult.FALSE; + } + + private AlarmEvalResult evalRepeating(boolean newEvent, CalculatedFieldCtx ctx) { + if (eval(condition.getExpression(), ctx)) { + if (newEvent) { + eventCount++; + } + long requiredRepeats = getIntValue(((RepeatingAlarmCondition) condition).getCount()); + if (requiredRepeats > 0) { + long leftRepeats = requiredRepeats - eventCount; + return leftRepeats <= 0 ? AlarmEvalResult.TRUE : AlarmEvalResult.notYetTrue(leftRepeats, 0); + } else { + return AlarmEvalResult.NOT_YET_TRUE; + } + } else { + return AlarmEvalResult.FALSE; + } + } + + private AlarmEvalResult evalDuration(CalculatedFieldCtx ctx) { + if (eval(condition.getExpression(), ctx)) { + long eventTs = state.getLatestTimestamp(); + if (lastEventTs > 0) { + if (eventTs > lastEventTs) { + if (firstEventTs == 0) { + firstEventTs = lastEventTs; + } + lastEventTs = eventTs; + } + } else { + firstEventTs = eventTs; + lastEventTs = eventTs; + } + duration = lastEventTs - firstEventTs; + long requiredDuration = getRequiredDurationInMs(); + if (requiredDuration > 0) { + long leftDuration = requiredDuration - duration; + if (leftDuration <= 0) { + return AlarmEvalResult.TRUE; + } else { + return AlarmEvalResult.notYetTrue(0, leftDuration); + } + } else { + return AlarmEvalResult.NOT_YET_TRUE; + } + } else { + return AlarmEvalResult.FALSE; + } + } + + private boolean isActive(long eventTs) { + if (condition.getSchedule() == null) { + return true; + } + AlarmSchedule schedule = state.resolveValue(condition.getSchedule(), entry -> Optional.ofNullable(KvUtil.getStringValue(entry)) + .map(this::parseSchedule).orElse(null)); + boolean active = switch (schedule.getType()) { + case ANY_TIME -> true; + case SPECIFIC_TIME -> isActiveSpecific((SpecificTimeSchedule) schedule, eventTs); + case CUSTOM -> isActiveCustom((CustomTimeSchedule) schedule, eventTs); + }; + log.trace("Alarm rule active = {} for schedule {}", active, schedule); + return active; + } + + private boolean isActiveSpecific(SpecificTimeSchedule schedule, long eventTs) { + ZoneId zoneId = SchedulerUtils.getZoneId(schedule.getTimezone()); + ZonedDateTime zdt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(eventTs), zoneId); + if (schedule.getDaysOfWeek().size() != 7) { + int dayOfWeek = zdt.getDayOfWeek().getValue(); + if (!schedule.getDaysOfWeek().contains(dayOfWeek)) { + return false; + } + } + long endsOn = schedule.getEndsOn(); + if (endsOn == 0) { + // 24 hours in milliseconds + endsOn = 86400000; + } + + return isActive(eventTs, zoneId, zdt, schedule.getStartsOn(), endsOn); + } + + private boolean isActiveCustom(CustomTimeSchedule schedule, long eventTs) { + ZoneId zoneId = SchedulerUtils.getZoneId(schedule.getTimezone()); + ZonedDateTime zdt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(eventTs), zoneId); + int dayOfWeek = zdt.toLocalDate().getDayOfWeek().getValue(); + for (CustomTimeScheduleItem item : schedule.getItems()) { + if (item.getDayOfWeek() == dayOfWeek) { + if (item.isEnabled()) { + long endsOn = item.getEndsOn(); + if (endsOn == 0) { + // 24 hours in milliseconds + endsOn = 86400000; + } + return isActive(eventTs, zoneId, zdt, item.getStartsOn(), endsOn); + } else { + return false; + } + } + } + return false; + } + + private boolean isActive(long eventTs, ZoneId zoneId, ZonedDateTime zdt, long startsOn, long endsOn) { + long startOfDay = zdt.toLocalDate().atStartOfDay(zoneId).toInstant().toEpochMilli(); + long msFromStartOfDay = eventTs - startOfDay; + if (startsOn <= endsOn) { + return startsOn <= msFromStartOfDay && endsOn > msFromStartOfDay; + } else { + return startsOn < msFromStartOfDay || (0 < msFromStartOfDay && msFromStartOfDay < endsOn); + } + } + + public void clear() { + clearRepeatingConditionState(); + clearDurationConditionState(); + } + + private void clearRepeatingConditionState() { + eventCount = 0L; + } + + private void clearDurationConditionState() { + firstEventTs = 0L; + lastEventTs = 0L; + duration = 0L; + if (durationCheckFuture != null) { + durationCheckFuture.cancel(true); + durationCheckFuture = null; + } + } + + public boolean isEmpty() { + return eventCount == 0L && firstEventTs == 0L && lastEventTs == 0L && durationCheckFuture == null; + } + + private AlarmSchedule parseSchedule(String str) { + ObjectNode json = (ObjectNode) JacksonUtil.toJsonNode(str); + if (json.isEmpty()) { + return new AnyTimeSchedule(); // only if valid json, fail otherwise + } + + if (!json.hasNonNull("type")) { + // deducting the schedule type + AlarmScheduleType type; + if (json.hasNonNull("daysOfWeek")) { + type = AlarmScheduleType.SPECIFIC_TIME; + } else if (json.hasNonNull("items")) { + type = AlarmScheduleType.CUSTOM; + } else { + throw new IllegalArgumentException("Failed to parse alarm schedule from '" + str + "'"); + } + json.put("type", type.name()); + } + + return JacksonUtil.treeToValue(json, AlarmSchedule.class); + } + + private Integer getIntValue(AlarmConditionValue value) { + return state.resolveValue(value, entry -> Optional.ofNullable(KvUtil.getLongValue(entry)).map(Long::intValue).orElse(null)); + } + + private long getRequiredDurationInMs() { + DurationAlarmCondition durationCondition = (DurationAlarmCondition) condition; + return durationCondition.getUnit().toMillis(state.resolveValue(durationCondition.getValue(), KvUtil::getLongValue)); + } + + private boolean eval(AlarmConditionExpression expression, CalculatedFieldCtx ctx) { + return state.eval(expression, ctx); + } + + public void setAlarmRule(AlarmRule alarmRule) { + this.alarmRule = alarmRule; + this.condition = alarmRule.getCondition(); + + // clearing state for other condition types (possibly left from a previous condition type) + switch (condition.getType()) { + case SIMPLE -> { + clearRepeatingConditionState(); + clearDurationConditionState(); + } + case REPEATING -> { + clearDurationConditionState(); + } + case DURATION -> { + clearRepeatingConditionState(); + } + } + } + + public StateInfo getStateInfo() { + if (condition.getType() == AlarmConditionType.REPEATING) { + return new StateInfo(eventCount, null); + } else if (condition.getType() == AlarmConditionType.DURATION) { + return new StateInfo(null, duration); + } else { + return StateInfo.EMPTY; + } + } + + @Override + public String toString() { + return "AlarmRuleState{" + + "severity=" + severity + + ", condition=" + condition + + ", eventCount=" + eventCount + + ", firstEventTs=" + firstEventTs + + ", lastEventTs=" + lastEventTs + + ", duration=" + duration + + ", durationCheckFuture=" + durationCheckFuture + + '}'; + } + + public record StateInfo(Long eventCount, Long duration) { + static final StateInfo EMPTY = new StateInfo(null, null); + + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java index f526cc00ab..bcc4d3ffcd 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java @@ -18,7 +18,7 @@ package org.thingsboard.server.service.cf.ctx.state.geofencing; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.thingsboard.script.api.tbel.TbelCfArg; -import org.thingsboard.script.api.tbel.TbelCfTsGeofencingArg; +import org.thingsboard.script.api.tbel.TbelCfGeofencingArg; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.util.ProtoUtils; @@ -41,7 +41,7 @@ public class GeofencingArgumentEntry implements ArgumentEntry { } public GeofencingArgumentEntry(EntityId entityId, TransportProtos.AttributeValueProto entry) { - this.zoneStates = toZones(Map.of(entityId, ProtoUtils.fromProto(entry))); + this(Map.of(entityId, ProtoUtils.fromProto(entry))); } public GeofencingArgumentEntry(Map entityIdkvEntryMap) { @@ -63,6 +63,10 @@ public class GeofencingArgumentEntry implements ArgumentEntry { if (!(entry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) { throw new IllegalArgumentException("Unsupported argument entry type for geofencing argument entry: " + entry.getType()); } + if (geofencingArgumentEntry.isEmpty()) { + zoneStates.clear(); + return true; + } boolean updated = false; for (var zoneEntry : geofencingArgumentEntry.getZoneStates().entrySet()) { if (updateZone(zoneEntry)) { @@ -79,7 +83,7 @@ public class GeofencingArgumentEntry implements ArgumentEntry { @Override public TbelCfArg toTbelCfArg() { - return new TbelCfTsGeofencingArg(zoneStates); + return new TbelCfGeofencingArg(zoneStates); } private Map toZones(Map entityIdKvEntryMap) { @@ -97,6 +101,10 @@ public class GeofencingArgumentEntry implements ArgumentEntry { zoneStates.put(zoneId, newZoneState); return true; } + if (newZoneState.getPerimeterDefinition() == null) { + zoneStates.remove(zoneId); + return true; + } return existingZoneState.update(newZoneState); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java index 506ddcff78..51110df2bb 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java @@ -15,16 +15,19 @@ */ package org.thingsboard.server.service.cf.ctx.state.geofencing; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; -import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.geo.Coordinates; import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent; @@ -32,6 +35,7 @@ import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupC import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.service.cf.CalculatedFieldResult; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType; import org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState; @@ -39,7 +43,6 @@ import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -49,20 +52,16 @@ import static org.thingsboard.server.common.data.cf.configuration.geofencing.Ent import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.INSIDE; import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.OUTSIDE; -@Data +@Getter +@Setter @Slf4j @EqualsAndHashCode(callSuper = true) public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { - private boolean dirty; + private long lastDynamicArgumentsRefreshTs = -1; - public GeofencingCalculatedFieldState() { - super(new ArrayList<>(), new HashMap<>(), false, -1); - this.dirty = false; - } - - public GeofencingCalculatedFieldState(List argNames) { - super(argNames); + public GeofencingCalculatedFieldState(EntityId entityId) { + super(entityId); } @Override @@ -71,53 +70,25 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { } @Override - public boolean updateState(CalculatedFieldCtx ctx, Map argumentValues) { - if (arguments == null) { - arguments = new HashMap<>(); - } - - boolean stateUpdated = false; - - for (var entry : argumentValues.entrySet()) { - String key = entry.getKey(); - ArgumentEntry newEntry = entry.getValue(); - - checkArgumentSize(key, newEntry, ctx); - - ArgumentEntry existingEntry = arguments.get(key); - boolean entryUpdated; - - if (existingEntry == null || newEntry.isForceResetPrevious()) { - entryUpdated = switch (key) { - case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> { - if (!(newEntry instanceof SingleValueArgumentEntry singleValueArgumentEntry)) { - throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " + - "Only SINGLE_VALUE type is allowed."); - } - arguments.put(key, singleValueArgumentEntry); - yield true; - } - default -> { - if (!(newEntry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) { - throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " + - "Only GEOFENCING type is allowed."); - } - arguments.put(key, geofencingArgumentEntry); - yield true; - } - }; - } else { - entryUpdated = existingEntry.updateEntry(newEntry); + protected void validateNewEntry(String key, ArgumentEntry newEntry) { + switch (key) { + case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> { + if (!(newEntry instanceof SingleValueArgumentEntry)) { + throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " + + "Only SINGLE_VALUE type is allowed."); + } } - if (entryUpdated) { - stateUpdated = true; + default -> { + if (!(newEntry instanceof GeofencingArgumentEntry)) { + throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " + + "Only GEOFENCING type is allowed."); + } } } - return stateUpdated; } @Override - public ListenableFuture performCalculation(EntityId entityId, CalculatedFieldCtx ctx) { + public ListenableFuture performCalculation(Map updatedArgs, CalculatedFieldCtx ctx) { double latitude = (double) arguments.get(ENTITY_ID_LATITUDE_ARGUMENT_KEY).getValue(); double longitude = (double) arguments.get(ENTITY_ID_LONGITUDE_ARGUMENT_KEY).getValue(); Coordinates entityCoordinates = new Coordinates(latitude, longitude); @@ -125,7 +96,7 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { var geofencingCfg = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); Map zoneGroups = geofencingCfg.getZoneGroups(); - ObjectNode resultNode = JacksonUtil.newObjectNode(); + ObjectNode valuesNode = JacksonUtil.newObjectNode(); List> relationFutures = new ArrayList<>(); getGeofencingArguments().forEach((argumentKey, argumentEntry) -> { @@ -154,16 +125,31 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { relationFutures.add(f); } }); - updateResultNode(argumentKey, zoneResults, zoneGroupCfg.getReportStrategy(), resultNode); + updateValuesNode(argumentKey, zoneResults, zoneGroupCfg.getReportStrategy(), valuesNode); }); - var result = new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), resultNode); + OutputType outputType = ctx.getOutput().getType(); + var result = TelemetryCalculatedFieldResult.builder() + .type(outputType) + .scope(ctx.getOutput().getScope()) + .result(toResultNode(outputType, valuesNode)) + .build(); if (relationFutures.isEmpty()) { return Futures.immediateFuture(result); } return Futures.whenAllComplete(relationFutures).call(() -> result, MoreExecutors.directExecutor()); } + @Override + public void reset() { + super.reset(); + lastDynamicArgumentsRefreshTs = -1; + } + + public void updateLastDynamicArgumentsRefreshTs() { + lastDynamicArgumentsRefreshTs = System.currentTimeMillis(); + } + private Map getGeofencingArguments() { return arguments.entrySet() .stream() @@ -171,7 +157,7 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { .collect(Collectors.toMap(Map.Entry::getKey, entry -> (GeofencingArgumentEntry) entry.getValue())); } - private void updateResultNode(String argumentKey, List zoneResults, GeofencingReportStrategy geofencingReportStrategy, ObjectNode resultNode) { + private void updateValuesNode(String argumentKey, List zoneResults, GeofencingReportStrategy geofencingReportStrategy, ObjectNode resultNode) { GeofencingEvalResult aggregationResult = aggregateZoneGroup(zoneResults); final String eventKey = argumentKey + "Event"; final String statusKey = argumentKey + "Status"; @@ -185,6 +171,10 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { } } + private JsonNode toResultNode(OutputType outputType, ObjectNode valuesNode) { + return toSimpleResult(outputType == OutputType.TIME_SERIES, valuesNode); + } + private GeofencingEvalResult aggregateZoneGroup(List zoneResults) { boolean nowInside = zoneResults.stream().anyMatch(r -> INSIDE.equals(r.status())); boolean prevInside = zoneResults.stream() diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java new file mode 100644 index 0000000000..81009da5e5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java @@ -0,0 +1,73 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state.propagation; + +import lombok.Data; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfPropagationArg; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.util.CollectionsUtil; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class PropagationArgumentEntry implements ArgumentEntry { + + private List propagationEntityIds; + + private boolean forceResetPrevious; + + public PropagationArgumentEntry(List propagationEntityIds) { + this.propagationEntityIds = new ArrayList<>(propagationEntityIds); + } + + @Override + public ArgumentEntryType getType() { + return ArgumentEntryType.PROPAGATION; + } + + @Override + public Object getValue() { + return propagationEntityIds; + } + + @Override + public boolean updateEntry(ArgumentEntry entry) { + if (!(entry instanceof PropagationArgumentEntry propagationArgumentEntry)) { + throw new IllegalArgumentException("Unsupported argument entry type for propagation argument entry: " + entry.getType()); + } + if (propagationArgumentEntry.isEmpty()) { + propagationEntityIds.clear(); + } else { + propagationEntityIds = propagationArgumentEntry.getPropagationEntityIds(); + } + return true; + } + + @Override + public boolean isEmpty() { + return CollectionsUtil.isEmpty(propagationEntityIds); + } + + @Override + public TbelCfArg toTbelCfArg() { + return new TbelCfPropagationArg(propagationEntityIds); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java new file mode 100644 index 0000000000..4f589572e8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java @@ -0,0 +1,107 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state.propagation; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.actors.TbActorRef; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.service.cf.CalculatedFieldResult; +import org.thingsboard.server.service.cf.PropagationCalculatedFieldResult; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; + +import java.util.ArrayList; +import java.util.Map; + +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; + +public class PropagationCalculatedFieldState extends ScriptCalculatedFieldState { + + public PropagationCalculatedFieldState(EntityId entityId) { + super(entityId); + } + + @Override + public void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx) { + this.ctx = ctx; + this.actorCtx = actorCtx; + this.requiredArguments = new ArrayList<>(ctx.getArgNames()); + requiredArguments.add(PROPAGATION_CONFIG_ARGUMENT); + this.readinessStatus = checkReadiness(requiredArguments, arguments); + if (ctx.isApplyExpressionForResolvedArguments()) { + this.tbelExpression = ctx.getTbelExpressions().get(ctx.getExpression()); + } + } + + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.PROPAGATION; + } + + @Override + public ListenableFuture performCalculation(Map updatedArgs, CalculatedFieldCtx ctx) { + ArgumentEntry argumentEntry = arguments.get(PROPAGATION_CONFIG_ARGUMENT); + if (!(argumentEntry instanceof PropagationArgumentEntry propagationArgumentEntry) || propagationArgumentEntry.isEmpty()) { + return Futures.immediateFuture(PropagationCalculatedFieldResult.builder().build()); + } + if (ctx.isApplyExpressionForResolvedArguments()) { + return Futures.transform(super.performCalculation(updatedArgs, ctx), telemetryCfResult -> + PropagationCalculatedFieldResult.builder() + .propagationEntityIds(propagationArgumentEntry.getPropagationEntityIds()) + .result((TelemetryCalculatedFieldResult) telemetryCfResult) + .build(), + MoreExecutors.directExecutor()); + } + return Futures.immediateFuture(PropagationCalculatedFieldResult.builder() + .propagationEntityIds(propagationArgumentEntry.getPropagationEntityIds()) + .result(toTelemetryResult(ctx)) + .build()); + } + + private TelemetryCalculatedFieldResult toTelemetryResult(CalculatedFieldCtx ctx) { + Output output = ctx.getOutput(); + TelemetryCalculatedFieldResult.TelemetryCalculatedFieldResultBuilder telemetryCfBuilder = + TelemetryCalculatedFieldResult.builder() + .type(output.getType()) + .scope(output.getScope()); + ObjectNode valuesNode = JacksonUtil.newObjectNode(); + arguments.forEach((outputKey, argumentEntry) -> { + if (argumentEntry instanceof PropagationArgumentEntry) { + return; + } + if (argumentEntry instanceof SingleValueArgumentEntry singleArgumentEntry) { + JacksonUtil.addKvEntry(valuesNode, singleArgumentEntry.getKvEntryValue(), outputKey); + return; + } + throw new IllegalArgumentException("Unsupported argument type: " + argumentEntry.getType() + " detected for argument: " + outputKey + ". " + + "Only Latest telemetry or Attribute arguments supported for 'Arguments Only' propagation mode!"); + }); + ObjectNode result = toSimpleResult(output.getType() == OutputType.TIME_SERIES, valuesNode); + telemetryCfBuilder.result(result); + return telemetryCfBuilder.build(); + } + +} 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 d042fb2657..782104cc9b 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 @@ -259,7 +259,7 @@ public class DeviceBulkImportService extends AbstractBulkImportService { Lwm2mDeviceProfileTransportConfiguration transportConfiguration = new Lwm2mDeviceProfileTransportConfiguration(); transportConfiguration.setBootstrap(Collections.emptyList()); transportConfiguration.setClientLwM2mSettings(new OtherConfiguration(false,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(), SINGLE)); + transportConfiguration.setObserveAttr(new TelemetryMappingConfiguration(Collections.emptyMap(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptyMap(), false, SINGLE)); DeviceProfileData deviceProfileData = new DeviceProfileData(); DefaultDeviceProfileConfiguration configuration = new DefaultDeviceProfileConfiguration(); diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java index 0778d61ee7..ffd16c1287 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java @@ -186,9 +186,14 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { try { Optional provisionState = attributesService.find(device.getTenantId(), device.getId(), AttributeScope.SERVER_SCOPE, DEVICE_PROVISION_STATE).get(); - if (provisionState != null && provisionState.isPresent() && !provisionState.get().getValueAsString().equals(PROVISIONED_STATE)) { - notify(device, provisionRequest, TbMsgType.PROVISION_FAILURE, false); - throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + if (provisionState != null && provisionState.isPresent()) { + if (provisionState.get().getValueAsString().equals(PROVISIONED_STATE)) { + notify(device, provisionRequest, TbMsgType.PROVISION_FAILURE, false); + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } else { + log.error("[{}][{}] Unknown provision state: {}!", device.getName(), DEVICE_PROVISION_STATE, provisionState.get().getValueAsString()); + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } } else { saveProvisionStateAttribute(device).get(); notify(device, provisionRequest, TbMsgType.PROVISION_SUCCESS, true); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java index 0f469b15b8..ec4a43310b 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java @@ -24,6 +24,7 @@ import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.asset.AssetProfileService; @@ -59,6 +60,7 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.rpc.EdgeEventStorageSettings; import org.thingsboard.server.service.edge.rpc.EdgeRpcService; import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.ai.AiModelProcessor; import org.thingsboard.server.service.edge.rpc.processor.alarm.AlarmProcessor; import org.thingsboard.server.service.edge.rpc.processor.alarm.comment.AlarmCommentProcessor; import org.thingsboard.server.service.edge.rpc.processor.asset.AssetEdgeProcessor; @@ -262,6 +264,12 @@ public class EdgeContextComponent { @Autowired private CalculatedFieldProcessor calculatedFieldProcessor; + @Autowired + private AiModelService aiModelService; + + @Autowired + private AiModelProcessor aiModelProcessor; + @Autowired private UserProcessor userProcessor; diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java index fda9a1d17b..825b911403 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java @@ -113,7 +113,7 @@ public class EdgeEventSourcingListener { return; } try { - if (EntityType.TENANT == entityType || EntityType.EDGE == entityType || EntityType.AI_MODEL == entityType) { + if (EntityType.TENANT == entityType || EntityType.EDGE == entityType) { return; } log.trace("[{}] DeleteEntityEvent called: {}", tenantId, event); @@ -227,7 +227,7 @@ public class EdgeEventSourcingListener { break; case TENANT: return !event.getCreated(); - case API_USAGE_STATE, EDGE, AI_MODEL: + case API_USAGE_STATE, EDGE: return false; case DOMAIN: if (entity instanceof Domain domain) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java index 192c56692d..505b03a1cd 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java @@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.asset.Asset; @@ -52,6 +53,7 @@ import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.domain.DomainInfo; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CalculatedFieldId; @@ -86,6 +88,7 @@ import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; @@ -654,4 +657,17 @@ public class EdgeMsgConstructorUtils { .setIdLSB(calculatedFieldId.getId().getLeastSignificantBits()).build(); } + public static AiModelUpdateMsg constructAiModelUpdatedMsg(UpdateMsgType msgType, AiModel aiModel) { + return AiModelUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(aiModel)) + .setIdMSB(aiModel.getId().getId().getMostSignificantBits()) + .setIdLSB(aiModel.getId().getId().getLeastSignificantBits()).build(); + } + + public static AiModelUpdateMsg constructAiModelDeleteMsg(AiModelId aiModelId) { + return AiModelUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(aiModelId.getId().getMostSignificantBits()) + .setIdLSB(aiModelId.getId().getLeastSignificantBits()).build(); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java index 8a111e4d9d..d942dc2277 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java @@ -54,16 +54,18 @@ public class RelatedEdgesSourcingListener { @TransactionalEventListener(fallbackExecution = true) public void handleEvent(ActionEntityEvent event) { - executorService.submit(() -> { - log.trace("[{}] ActionEntityEvent called: {}", event.getTenantId(), event); - try { - switch (event.getActionType()) { - case ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE -> relatedEdgesService.publishRelatedEdgeIdsEvictEvent(event.getTenantId(), event.getEntityId()); - } - } catch (Exception e) { - log.error("[{}] failed to process ActionEntityEvent: {}", event.getTenantId(), event, e); + switch (event.getActionType()) { + case ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE -> { + executorService.submit(() -> { + log.trace("[{}] ActionEntityEvent called: {}", event.getTenantId(), event); + try { + relatedEdgesService.publishRelatedEdgeIdsEvictEvent(event.getTenantId(), event.getEntityId()); + } catch (Exception e) { + log.error("[{}] failed to process ActionEntityEvent: {}", event.getTenantId(), event, e); + } + }); } - }); + } } @TransactionalEventListener( diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index 7340696788..9fcb7425b2 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -93,14 +93,13 @@ import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAS @TbCoreComponent public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase implements EdgeRpcService { - private static final int DESTROY_SESSION_MAX_ATTEMPTS = 10; - private final ConcurrentMap sessions = new ConcurrentHashMap<>(); private final ConcurrentMap sessionNewEventsLocks = new ConcurrentHashMap<>(); private final Map sessionNewEvents = new HashMap<>(); private final ConcurrentMap> sessionEdgeEventChecks = new ConcurrentHashMap<>(); private final ConcurrentMap> localSyncEdgeRequests = new ConcurrentHashMap<>(); private final ConcurrentMap edgeEventsMigrationProcessed = new ConcurrentHashMap<>(); + private final List zombieSessions = new ArrayList<>(); @Value("${edges.rpc.port}") private int rpcPort; @@ -193,7 +192,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i this.edgeEventProcessingExecutorService = ThingsBoardExecutors.newScheduledThreadPool(schedulerPoolSize, "edge-event-check-scheduler"); this.sendDownlinkExecutorService = ThingsBoardExecutors.newScheduledThreadPool(sendSchedulerPoolSize, "edge-send-scheduler"); this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service"); - this.executorService.scheduleAtFixedRate(this::destroyKafkaSessionIfDisconnectedAndConsumerActive, 60, 60, TimeUnit.SECONDS); + this.executorService.scheduleAtFixedRate(this::cleanupZombieSessions, 60, 60, TimeUnit.SECONDS); log.info("Edge RPC service initialized!"); } @@ -518,14 +517,10 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private void destroySession(EdgeGrpcSession session) { try (session) { - for (int i = 0; i < DESTROY_SESSION_MAX_ATTEMPTS; i++) { - if (session.destroy()) { - break; - } else { - try { - Thread.sleep(100); - } catch (InterruptedException ignored) {} - } + if (!session.destroy()) { + log.warn("[{}][{}] Session destroy failed for edge [{}] with session id [{}]. Adding to zombie queue for later cleanup.", + session.getTenantId(), session.getEdge().getId(), session.getEdge().getName(), session.getSessionId()); + zombieSessions.add(session); } } } @@ -634,7 +629,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } } - private void destroyKafkaSessionIfDisconnectedAndConsumerActive() { + private void cleanupZombieSessions() { try { List toRemove = new ArrayList<>(); for (EdgeGrpcSession session : sessions.values()) { @@ -655,9 +650,19 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } } } + zombieSessions.removeIf(zombie -> { + if (zombie.destroy()) { + log.info("[{}][{}] Successfully cleaned up zombie session [{}] for edge [{}].", + zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName()); + return true; + } else { + log.warn("[{}][{}] Failed to remove zombie session [{}] for edge [{}].", + zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName()); + return false; + } + }); } catch (Exception e) { log.warn("Failed to cleanup kafka sessions", e); } } - } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java index 393d1ba3da..14c1b5e30a 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java @@ -46,6 +46,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; import org.thingsboard.server.dao.edge.stats.EdgeStatsKey; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; @@ -938,6 +939,11 @@ public abstract class EdgeGrpcSession implements Closeable { result.add(ctx.getCalculatedFieldProcessor().processCalculatedFieldMsgFromEdge(edge.getTenantId(), edge, calculatedFieldUpdateMsg)); } } + if (uplinkMsg.getAiModelUpdateMsgCount() > 0) { + for (AiModelUpdateMsg aiModelUpdateMsg : uplinkMsg.getAiModelUpdateMsgList()) { + result.add(ctx.getAiModelProcessor().processAiModelMsgFromEdge(edge.getTenantId(), edge, aiModelUpdateMsg)); + } + } if (uplinkMsg.getUserUpdateMsgCount() > 0) { for (UserUpdateMsg userUpdateMsg : uplinkMsg.getUserUpdateMsgList()) { sequenceDependencyLock.lock(); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java index a2243a88d2..d6bfeae0b7 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java @@ -139,7 +139,7 @@ public abstract class BaseEdgeProcessor implements EdgeProcessor { UPDATED_COMMENT, DELETED -> true; default -> switch (type) { case ALARM, ALARM_COMMENT, RULE_CHAIN, RULE_CHAIN_METADATA, USER, CUSTOMER, TENANT, TENANT_PROFILE, - WIDGETS_BUNDLE, WIDGET_TYPE, ADMIN_SETTINGS, OTA_PACKAGE, QUEUE, RELATION, CALCULATED_FIELD, NOTIFICATION_TEMPLATE, + WIDGETS_BUNDLE, WIDGET_TYPE, ADMIN_SETTINGS, OTA_PACKAGE, QUEUE, RELATION, CALCULATED_FIELD, AI_MODEL, NOTIFICATION_TEMPLATE, NOTIFICATION_TARGET, NOTIFICATION_RULE -> true; default -> false; }; diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java new file mode 100644 index 0000000000..74ca7ac27a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java @@ -0,0 +1,130 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.edge.rpc.processor.ai; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; + +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Component +@TbCoreComponent +public class AiModelEdgeProcessor extends BaseAiModelProcessor implements AiModelProcessor { + + @Override + public ListenableFuture processAiModelMsgFromEdge(TenantId tenantId, Edge edge, AiModelUpdateMsg aiModelUpdateMsg) { + AiModelId aiModelId = new AiModelId(new UUID(aiModelUpdateMsg.getIdMSB(), aiModelUpdateMsg.getIdLSB())); + try { + edgeSynchronizationManager.getEdgeId().set(edge.getId()); + + switch (aiModelUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE: + case ENTITY_UPDATED_RPC_MESSAGE: + processAiModel(tenantId, aiModelId, aiModelUpdateMsg, edge); + return Futures.immediateFuture(null); + case UNRECOGNIZED: + default: + return handleUnsupportedMsgType(aiModelUpdateMsg.getMsgType()); + } + } catch (DataValidationException e) { + return Futures.immediateFailedFuture(e); + } finally { + edgeSynchronizationManager.getEdgeId().remove(); + } + } + + @Override + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { + AiModelId aiModelId = new AiModelId(edgeEvent.getEntityId()); + switch (edgeEvent.getAction()) { + case ADDED, UPDATED -> { + Optional aiModel = edgeCtx.getAiModelService().findAiModelById(edgeEvent.getTenantId(), aiModelId); + if (aiModel.isPresent()) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + AiModelUpdateMsg aiModelUpdateMsg = EdgeMsgConstructorUtils.constructAiModelUpdatedMsg(msgType, aiModel.get()); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addAiModelUpdateMsg(aiModelUpdateMsg) + .build(); + } + } + case DELETED -> { + AiModelUpdateMsg aiModelUpdateMsg = EdgeMsgConstructorUtils.constructAiModelDeleteMsg(aiModelId); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addAiModelUpdateMsg(aiModelUpdateMsg) + .build(); + } + } + return null; + } + + @Override + public EdgeEventType getEdgeEventType() { + return EdgeEventType.AI_MODEL; + } + + private void processAiModel(TenantId tenantId, AiModelId aiModelId, AiModelUpdateMsg aiModelUpdateMsg, Edge edge) { + Pair resultPair = super.saveOrUpdateAiModel(tenantId, aiModelId, aiModelUpdateMsg); + Boolean wasCreated = resultPair.getFirst(); + if (wasCreated) { + pushAiModelCreatedEventToRuleEngine(tenantId, edge, aiModelId); + } + Boolean nameWasUpdated = resultPair.getSecond(); + if (nameWasUpdated) { + saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.AI_MODEL, EdgeEventActionType.UPDATED, aiModelId, null); + } + } + + private void pushAiModelCreatedEventToRuleEngine(TenantId tenantId, Edge edge, AiModelId aiModelId) { + try { + Optional aiModel = edgeCtx.getAiModelService().findAiModelById(tenantId, aiModelId); + if (aiModel.isPresent()) { + String aiModelAsString = JacksonUtil.toString(aiModel.get()); + TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, edge.getCustomerId()); + pushEntityEventToRuleEngine(tenantId, aiModelId, edge.getCustomerId(), TbMsgType.ENTITY_CREATED, aiModelAsString, msgMetaData); + } else { + log.warn("[{}][{}] Failed to find aiModel", tenantId, aiModelId); + } + } catch (Exception e) { + log.warn("[{}][{}] Failed to push aiModel action to rule engine: {}", tenantId, aiModelId, TbMsgType.ENTITY_CREATED.name(), e); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelProcessor.java new file mode 100644 index 0000000000..f66421167a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelProcessor.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.edge.rpc.processor.ai; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor; + +public interface AiModelProcessor extends EdgeProcessor { + + ListenableFuture processAiModelMsgFromEdge(TenantId tenantId, Edge edge, AiModelUpdateMsg aiModelUpdateMsg); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/BaseAiModelProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/BaseAiModelProcessor.java new file mode 100644 index 0000000000..cb1d27e0a1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/BaseAiModelProcessor.java @@ -0,0 +1,81 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.edge.rpc.processor.ai; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.util.Pair; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +import java.util.Optional; + +@Slf4j +public abstract class BaseAiModelProcessor extends BaseEdgeProcessor { + + @Autowired + private DataValidator aiModelValidator; + + protected Pair saveOrUpdateAiModel(TenantId tenantId, AiModelId aiModelId, AiModelUpdateMsg aiModelUpdateMsg) { + boolean isCreated = false; + boolean isNameUpdated = false; + try { + AiModel aiModel = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true); + if (aiModel == null) { + throw new RuntimeException("[{" + tenantId + "}] aiModelUpdateMsg {" + aiModelUpdateMsg + " } cannot be converted to aiModel"); + } + + Optional aiModelById = edgeCtx.getAiModelService().findAiModelById(tenantId, aiModelId); + if (aiModelById.isEmpty()) { + aiModel.setCreatedTime(Uuids.unixTimestamp(aiModelId.getId())); + isCreated = true; + aiModel.setId(null); + } else { + aiModel.setId(aiModelId); + } + + String aiModelName = aiModel.getName(); + Optional aiModelByName = edgeCtx.getAiModelService().findAiModelByTenantIdAndName(aiModel.getTenantId(), aiModelName); + if (aiModelByName.isPresent() && !aiModelByName.get().getId().equals(aiModelId)) { + aiModelName = aiModelName + "_" + StringUtils.randomAlphabetic(15); + log.warn("[{}] aiModel with name {} already exists. Renaming aiModel name to {}", + tenantId, aiModel.getName(), aiModelByName.get().getName()); + isNameUpdated = true; + } + aiModel.setName(aiModelName); + + aiModelValidator.validate(aiModel, AiModel::getTenantId); + + if (isCreated) { + aiModel.setId(aiModelId); + } + + edgeCtx.getAiModelService().save(aiModel, false); + } catch (Exception e) { + log.error("[{}] Failed to process aiModel update msg [{}]", tenantId, aiModelUpdateMsg, e); + throw e; + } + return Pair.of(isCreated, isNameUpdated); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/alarm/BaseAlarmProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/alarm/BaseAlarmProcessor.java index e8c2f65975..d6fd2f6968 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/alarm/BaseAlarmProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/alarm/BaseAlarmProcessor.java @@ -77,7 +77,7 @@ public abstract class BaseAlarmProcessor extends BaseEdgeProcessor { case ALARM_CLEAR_RPC_MESSAGE: Alarm alarmToClear = edgeCtx.getAlarmService().findAlarmById(tenantId, alarmId); if (alarmToClear != null) { - edgeCtx.getAlarmService().clearAlarm(tenantId, alarmId, alarm.getClearTs(), alarm.getDetails()); + edgeCtx.getAlarmService().clearAlarm(tenantId, alarmId, alarm.getClearTs(), alarm.getDetails(), true); } break; case ENTITY_DELETED_RPC_MESSAGE: diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/BaseCalculatedFieldProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/BaseCalculatedFieldProcessor.java index 4ef6ec7ba2..c3f9300e24 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/BaseCalculatedFieldProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/BaseCalculatedFieldProcessor.java @@ -53,7 +53,7 @@ public abstract class BaseCalculatedFieldProcessor extends BaseEdgeProcessor { } String calculatedFieldName = calculatedField.getName(); - CalculatedField calculatedFieldByName = edgeCtx.getCalculatedFieldService().findByEntityIdAndName(calculatedField.getEntityId(), calculatedFieldName); + CalculatedField calculatedFieldByName = edgeCtx.getCalculatedFieldService().findByEntityIdAndTypeAndName(calculatedField.getEntityId(), calculatedField.getType(), calculatedFieldName); if (calculatedFieldByName != null && !calculatedFieldByName.getId().equals(calculatedFieldId)) { calculatedFieldName = calculatedFieldName + "_" + StringUtils.randomAlphabetic(15); log.warn("[{}] calculatedField with name {} already exists. Renaming calculatedField name to {}", diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java index 03ab77ac09..9a6a96155f 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java @@ -22,8 +22,10 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.JobManager; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityType; @@ -31,9 +33,11 @@ import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.id.DeviceId; @@ -44,6 +48,7 @@ import org.thingsboard.server.common.data.job.Job; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.notification.NotificationRequest; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.data.security.DeviceCredentials; @@ -53,13 +58,18 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.rule.engine.DeviceCredentialsUpdateNotificationMsg; +import org.thingsboard.server.common.util.ProtoUtils; import org.thingsboard.server.dao.edge.EdgeSynchronizationManager; import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; +import org.thingsboard.server.dao.eventsourcing.RelationActionEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.gen.transport.TransportProtos.EntityActionEventProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; import org.thingsboard.server.queue.TbQueueCallback; -import org.thingsboard.rule.engine.api.JobManager; +import org.thingsboard.server.queue.TbQueueMsgMetadata; +import org.thingsboard.server.service.cf.CalculatedFieldCache; import java.util.Set; @@ -72,6 +82,7 @@ public class EntityStateSourcingListener { private final TbClusterService tbClusterService; private final EdgeSynchronizationManager edgeSynchronizationManager; private final JobManager jobManager; + private final CalculatedFieldCache calculatedFieldCache; @PostConstruct public void init() { @@ -140,6 +151,9 @@ public class EntityStateSourcingListener { case JOB -> { onJobUpdate((Job) event.getEntity()); } + case CUSTOMER -> { + tbClusterService.onCustomerUpdated((Customer) event.getEntity(), (Customer) event.getOldEntity()); + } default -> { } } @@ -153,7 +167,7 @@ public class EntityStateSourcingListener { return; } EntityType entityType = entityId.getEntityType(); - if (!tenantId.isSysTenantId() && entityType != EntityType.TENANT && !tenantService.tenantExists(tenantId)) { + if (entityType != EntityType.TENANT && !tenantExists(tenantId)) { log.debug("[{}] Ignoring DeleteEntityEvent because tenant does not exist: {}", tenantId, event); return; } @@ -216,18 +230,57 @@ public class EntityStateSourcingListener { @TransactionalEventListener(fallbackExecution = true) public void handleEvent(ActionEntityEvent event) { - log.trace("[{}] ActionEntityEvent called: {}", event.getTenantId(), event); - if (ActionType.CREDENTIALS_UPDATED.equals(event.getActionType()) && - EntityType.DEVICE.equals(event.getEntityId().getEntityType()) - && event.getEntity() instanceof DeviceCredentials) { - tbClusterService.pushMsgToCore(new DeviceCredentialsUpdateNotificationMsg(event.getTenantId(), - (DeviceId) event.getEntityId(), (DeviceCredentials) event.getEntity()), null); - } else if (ActionType.ASSIGNED_TO_TENANT.equals(event.getActionType()) && event.getEntity() instanceof Device device) { - Tenant tenant = JacksonUtil.fromString(event.getBody(), Tenant.class); - if (tenant != null) { - tbClusterService.onDeviceAssignedToTenant(tenant.getId(), device); + TenantId tenantId = event.getTenantId(); + log.trace("[{}] ActionEntityEvent called: {}", tenantId, event); + switch (event.getActionType()) { + case CREDENTIALS_UPDATED -> { + if (EntityType.DEVICE.equals(event.getEntityId().getEntityType()) && + event.getEntity() instanceof DeviceCredentials deviceCredentials) { + tbClusterService.pushMsgToCore(new DeviceCredentialsUpdateNotificationMsg(tenantId, + (DeviceId) event.getEntityId(), deviceCredentials), null); + } + } + case ASSIGNED_TO_TENANT -> { + if (event.getEntity() instanceof Device device) { + Tenant tenant = JacksonUtil.fromString(event.getBody(), Tenant.class); + if (tenant != null) { + tbClusterService.onDeviceAssignedToTenant(tenant.getId(), device); + } + pushAssignedFromNotification(tenant, tenantId, device); + } + } + case ALARM_ACK, ALARM_CLEAR, ALARM_DELETE -> { + if (event.getActionType() == ActionType.ALARM_DELETE && !tenantExists(tenantId)) { + return; + } + Alarm alarm = (Alarm) event.getEntity(); + if (calculatedFieldCache.hasCalculatedFields(tenantId, alarm.getOriginator(), ctx -> ctx.getCfType() == CalculatedFieldType.ALARM)) { + ToCalculatedFieldMsg msg = ToCalculatedFieldMsg.newBuilder() + .setEventMsg(toProto(event)) + .build(); + tbClusterService.pushMsgToCalculatedFields(tenantId, alarm.getOriginator(), msg, new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) {} + + @Override + public void onFailure(Throwable t) { + log.error("[{}] Failed to push alarm event to CF queue: {}", tenantId, event, t); + } + }); + } + } + } + } + + @TransactionalEventListener(fallbackExecution = true) + public void handleEvent(RelationActionEvent relationEvent) { + EntityRelation relation = relationEvent.getRelation(); + if (CalculatedField.isSupportedRefEntity(relation.getFrom()) && CalculatedField.isSupportedRefEntity(relation.getTo())) { + if (relationEvent.getActionType() == ActionType.RELATION_ADD_OR_UPDATE) { + tbClusterService.onRelationUpdated(relationEvent.getTenantId(), relation, TbQueueCallback.EMPTY); + } else if (relationEvent.getActionType() == ActionType.RELATION_DELETED) { + tbClusterService.onRelationDeleted(relationEvent.getTenantId(), relation, TbQueueCallback.EMPTY); } - pushAssignedFromNotification(tenant, event.getTenantId(), device); } } @@ -338,6 +391,10 @@ public class EntityStateSourcingListener { } } + private boolean tenantExists(TenantId tenantId) { + return tenantId.isSysTenantId() || tenantService.tenantExists(tenantId); + } + private TbMsgMetaData getMetaDataForAssignedFrom(Tenant tenant) { TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("assignedFromTenantId", tenant.getId().getId().toString()); @@ -345,4 +402,13 @@ public class EntityStateSourcingListener { return metaData; } + private EntityActionEventProto toProto(ActionEntityEvent event) { + return EntityActionEventProto.newBuilder() + .setTenantId(ProtoUtils.toProto(event.getTenantId())) + .setEntityId(ProtoUtils.toProto(event.getEntityId())) + .setAction(event.getActionType().name()) + .setEntity(event.getEntity() != null ? JacksonUtil.toString(event.getEntity()) : "") + .build(); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java index 3f69d00276..ef630c2df4 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java @@ -20,6 +20,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.audit.ActionType; @@ -40,10 +41,15 @@ public class DefaultTbAssetService extends AbstractTbEntityService implements Tb @Override public Asset save(Asset asset, User user) throws Exception { + return save(asset, NameConflictStrategy.DEFAULT, user); + } + + @Override + public Asset save(Asset asset, NameConflictStrategy nameConflictStrategy, User user) throws Exception { ActionType actionType = asset.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = asset.getTenantId(); try { - Asset savedAsset = checkNotNull(assetService.saveAsset(asset)); + Asset savedAsset = checkNotNull(assetService.saveAsset(asset, nameConflictStrategy)); autoCommit(user, savedAsset.getId()); logEntityActionService.logEntityAction(tenantId, savedAsset.getId(), savedAsset, asset.getCustomerId(), actionType, user); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java index a2af8ffcdc..42fce5213e 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.entitiy.asset; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.edge.Edge; @@ -27,6 +28,8 @@ public interface TbAssetService { Asset save(Asset asset, User user) throws Exception; + Asset save(Asset asset, NameConflictStrategy nameConflictStrategy, User user) throws Exception; + void delete(Asset asset, User user); Asset assignAssetToCustomer(TenantId tenantId, AssetId assetId, Customer customer, User user) throws ThingsboardException; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java index 4dfaec91cf..63f8a9bf2d 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java @@ -22,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; @@ -33,7 +34,7 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; import org.thingsboard.server.service.security.model.SecurityUser; -import java.util.Optional; +import java.util.Set; @TbCoreComponent @Service @@ -52,7 +53,7 @@ public class DefaultTbCalculatedFieldService extends AbstractTbEntityService imp CalculatedField existingCf = calculatedFieldService.findById(tenantId, calculatedField.getId()); checkForEntityChange(existingCf, calculatedField); } - checkEntityExistence(tenantId, calculatedField.getEntityId()); + checkEntity(tenantId, calculatedField.getEntityId(), calculatedField.getType()); CalculatedField savedCalculatedField = checkNotNull(calculatedFieldService.save(calculatedField)); logEntityActionService.logEntityAction(tenantId, savedCalculatedField.getId(), savedCalculatedField, actionType, user); return savedCalculatedField; @@ -68,10 +69,9 @@ public class DefaultTbCalculatedFieldService extends AbstractTbEntityService imp } @Override - public PageData findAllByTenantIdAndEntityId(EntityId entityId, SecurityUser user, PageLink pageLink) { - TenantId tenantId = user.getTenantId(); - checkEntityExistence(tenantId, entityId); - return calculatedFieldService.findAllCalculatedFieldsByEntityId(tenantId, entityId, pageLink); + public PageData findByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, CalculatedFieldType type, PageLink pageLink) { + checkEntity(tenantId, entityId, type); + return calculatedFieldService.findCalculatedFieldsByEntityId(tenantId, entityId, type, pageLink); } @Override @@ -95,11 +95,15 @@ public class DefaultTbCalculatedFieldService extends AbstractTbEntityService imp } } - private void checkEntityExistence(TenantId tenantId, EntityId entityId) { - switch (entityId.getEntityType()) { - case ASSET, DEVICE, ASSET_PROFILE, DEVICE_PROFILE -> Optional.ofNullable(entityService.fetchEntity(tenantId, entityId)) - .orElseThrow(() -> new IllegalArgumentException(entityId.getEntityType().getNormalName() + " with id [" + entityId.getId() + "] does not exist.")); - default -> throw new IllegalArgumentException("Entity type '" + entityId.getEntityType() + "' does not support calculated fields."); + private void checkEntity(TenantId tenantId, EntityId entityId, CalculatedFieldType type) { + EntityType entityType = entityId.getEntityType(); + Set supportedTypes = CalculatedField.SUPPORTED_ENTITIES.get(entityType); + if (supportedTypes == null || supportedTypes.isEmpty()) { + throw new IllegalArgumentException("Entity type '" + entityType + "' does not support calculated fields"); + } else if (type != null && !supportedTypes.contains(type)) { + throw new IllegalArgumentException("Entity type '" + entityType + "' does not support '" + type + "' calculated fields"); + } else if (entityService.fetchEntity(tenantId, entityId).isEmpty()) { + throw new IllegalArgumentException(entityType.getNormalName() + " with id [" + entityId.getId() + "] does not exist."); } } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java index 1e04a14a08..20705aaaff 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java @@ -16,9 +16,11 @@ package org.thingsboard.server.service.entitiy.cf; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.service.security.model.SecurityUser; @@ -29,7 +31,7 @@ public interface TbCalculatedFieldService { CalculatedField findById(CalculatedFieldId calculatedFieldId, SecurityUser user); - PageData findAllByTenantIdAndEntityId(EntityId entityId, SecurityUser user, PageLink pageLink); + PageData findByTenantIdAndEntityId(TenantId tenantId, EntityId entityId, CalculatedFieldType type, PageLink pageLink); void delete(CalculatedField calculatedField, SecurityUser user); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java index c070aea087..a2a0e6846d 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java @@ -19,6 +19,7 @@ import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.id.CustomerId; @@ -32,10 +33,15 @@ public class DefaultTbCustomerService extends AbstractTbEntityService implements @Override public Customer save(Customer customer, SecurityUser user) throws Exception { + return save(customer, NameConflictStrategy.DEFAULT, user); + } + + @Override + public Customer save(Customer customer, NameConflictStrategy nameConflictStrategy, SecurityUser user) throws Exception { ActionType actionType = customer.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = customer.getTenantId(); try { - Customer savedCustomer = checkNotNull(customerService.saveCustomer(customer)); + Customer savedCustomer = checkNotNull(customerService.saveCustomer(customer, nameConflictStrategy)); autoCommit(user, savedCustomer.getId()); logEntityActionService.logEntityAction(tenantId, savedCustomer.getId(), savedCustomer, null, actionType, user); return savedCustomer; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java index f77e990620..5884ae2e48 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java @@ -16,8 +16,12 @@ package org.thingsboard.server.service.entitiy.customer; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.service.entitiy.SimpleTbEntityService; +import org.thingsboard.server.service.security.model.SecurityUser; public interface TbCustomerService extends SimpleTbEntityService { + Customer save(Customer customer, NameConflictStrategy nameConflictStrategy, SecurityUser user) throws Exception; + } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java index f182894239..e982adbdf1 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java @@ -25,6 +25,7 @@ import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; @@ -56,10 +57,15 @@ public class DefaultTbDeviceService extends AbstractTbEntityService implements T @Override public Device save(Device device, String accessToken, User user) throws Exception { + return save(device, accessToken, NameConflictStrategy.DEFAULT, user); + } + + @Override + public Device save(Device device, String accessToken, NameConflictStrategy nameConflictStrategy, User user) throws Exception { ActionType actionType = device.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = device.getTenantId(); try { - Device savedDevice = checkNotNull(deviceService.saveDeviceWithAccessToken(device, accessToken)); + Device savedDevice = checkNotNull(deviceService.saveDeviceWithAccessToken(device, accessToken, nameConflictStrategy)); autoCommit(user, savedDevice.getId()); logEntityActionService.logEntityAction(tenantId, savedDevice.getId(), savedDevice, savedDevice.getCustomerId(), actionType, user); @@ -73,10 +79,15 @@ public class DefaultTbDeviceService extends AbstractTbEntityService implements T @Override public Device saveDeviceWithCredentials(Device device, DeviceCredentials credentials, User user) throws ThingsboardException { + return saveDeviceWithCredentials(device, credentials, NameConflictStrategy.DEFAULT, user); + } + + @Override + public Device saveDeviceWithCredentials(Device device, DeviceCredentials credentials, NameConflictStrategy nameConflictStrategy, User user) throws ThingsboardException { ActionType actionType = device.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = device.getTenantId(); try { - Device savedDevice = checkNotNull(deviceService.saveDeviceWithCredentials(device, credentials)); + Device savedDevice = checkNotNull(deviceService.saveDeviceWithCredentials(device, credentials, nameConflictStrategy)); logEntityActionService.logEntityAction(tenantId, savedDevice.getId(), savedDevice, savedDevice.getCustomerId(), actionType, user); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java index c234b3b597..e217d8de7b 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.service.entitiy.device; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.edge.Edge; @@ -33,8 +34,12 @@ public interface TbDeviceService { Device save(Device device, String accessToken, User user) throws Exception; + Device save(Device device, String accessToken, NameConflictStrategy nameConflictStrategy, User user) throws Exception; + Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, User user) throws ThingsboardException; + Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, NameConflictStrategy nameConflictStrategy, User user) throws ThingsboardException; + void delete(Device device, User user); Device assignDeviceToCustomer(TenantId tenantId, DeviceId deviceId, Customer customer, User user) throws ThingsboardException; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java index 8fb99bfb0e..54699e0995 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java @@ -33,6 +33,7 @@ import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.edge.Edge; @@ -80,10 +81,15 @@ public class DefaultTbEntityViewService extends AbstractTbEntityService implemen @Override public EntityView save(EntityView entityView, EntityView existingEntityView, User user) throws Exception { + return save(entityView, existingEntityView, NameConflictStrategy.DEFAULT, user); + } + + @Override + public EntityView save(EntityView entityView, EntityView existingEntityView, NameConflictStrategy nameConflictStrategy, User user) throws Exception { ActionType actionType = entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = entityView.getTenantId(); try { - EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView)); + EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView, nameConflictStrategy)); this.updateEntityViewAttributes(tenantId, savedEntityView, existingEntityView, user); autoCommit(user, savedEntityView.getId()); logEntityActionService.logEntityAction(savedEntityView.getTenantId(), savedEntityView.getId(), savedEntityView, diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java index 3aec924f75..bb4bcf9cad 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.service.entitiy.entityview; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -33,6 +34,8 @@ public interface TbEntityViewService extends ComponentLifecycleListener { EntityView save(EntityView entityView, EntityView existingEntityView, User user) throws Exception; + EntityView save(EntityView entityView, EntityView existingEntityView, NameConflictStrategy nameConflictStrategy, User user) throws Exception; + void updateEntityViewAttributes(TenantId tenantId, EntityView savedEntityView, EntityView oldEntityView, User user) throws ThingsboardException; void delete(EntityView entity, User user) throws ThingsboardException; diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java index f41a530630..500ad60df0 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java @@ -17,7 +17,6 @@ package org.thingsboard.server.service.install; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Profile; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.thingsboard.server.service.install.update.DefaultDataUpdateService; @@ -25,14 +24,13 @@ import org.thingsboard.server.service.install.update.DefaultDataUpdateService; import java.util.List; @Service -@Profile("install") @Slf4j @RequiredArgsConstructor public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSettingsService { - // This list should include all versions which are compatible for the upgrade. - // The compatibility cycle usually breaks when we have some scripts written in Java that may not work after new release. - private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.0"); + // This list should include all versions that are compatible for the upgrade in 4 digits format (like 4.2.0.0, etc.). + // The compatibility cycle usually breaks when we have some scripts written in Java that may not work after a new release. + private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.1.0"); private final ProjectInfo projectInfo; private final JdbcTemplate jdbcTemplate; @@ -80,7 +78,7 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti @Override public String getPackageSchemaVersion() { if (packageSchemaVersion == null) { - packageSchemaVersion = projectInfo.getProjectVersion(); + packageSchemaVersion = normalizeVersion(projectInfo.getProjectVersion()); } return packageSchemaVersion; } @@ -88,17 +86,28 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti @Override public String getDbSchemaVersion() { if (schemaVersionFromDb == null) { - Long version = getSchemaVersionFromDb(); - if (version == null) { + Long dbVersion = getSchemaVersionFromDb(); + if (dbVersion == null) { onSchemaSettingsError("Upgrade failed: the database schema version is missing."); } @SuppressWarnings("DataFlowIssue") - long major = version / 1000000; - long minor = (version % 1000000) / 1000; - long patch = version % 1000; - - schemaVersionFromDb = major + "." + minor + "." + patch; + long version = dbVersion; + + if (version < 1_000_000_000) { + // Old format: MMM mmm ppp (e.g., 4002001 = 4.2.1) + long major = version / 1_000_000; + long minor = (version % 1_000_000) / 1000; + long maintenance = version % 1000; + schemaVersionFromDb = major + "." + minor + "." + maintenance + ".0"; + } else { + // New format: MMM mmm mmm ppp (e.g., 4002001001 = 4.2.1.1) + long major = version / 1_000_000_000; + long minor = (version % 1_000_000_000) / 1_000_000; + long maintenance = (version % 1_000_000) / 1000; + long patch = version % 1000; + schemaVersionFromDb = major + "." + minor + "." + maintenance + "." + patch; + } } return schemaVersionFromDb; } @@ -116,13 +125,26 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti long major = Integer.parseInt(versionParts[0]); long minor = Integer.parseInt(versionParts[1]); - long patch = versionParts.length > 2 ? Integer.parseInt(versionParts[2]) : 0; + long maintenance = Integer.parseInt(versionParts[2]); + long patch = Integer.parseInt(versionParts[3]); - return major * 1000000 + minor * 1000 + patch; + return major * 1_000_000_000L + minor * 1_000_000L + maintenance * 1000L + patch; } private void onSchemaSettingsError(String message) { Runtime.getRuntime().addShutdownHook(new Thread(() -> log.error(message))); throw new RuntimeException(message); } + + private String normalizeVersion(String version) { + String[] parts = version.split("\\."); + + int major = Integer.parseInt(parts[0]); + int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0; + int maintenance = parts.length > 2 ? Integer.parseInt(parts[2]) : 0; + int patch = parts.length > 3 ? Integer.parseInt(parts[3]) : 0; + + return major + "." + minor + "." + maintenance + "." + patch; + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java index d580175aa0..a55832c823 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java @@ -49,17 +49,25 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.alarm.AlarmSeverity; -import org.thingsboard.server.common.data.device.profile.AlarmCondition; -import org.thingsboard.server.common.data.device.profile.AlarmConditionFilter; -import org.thingsboard.server.common.data.device.profile.AlarmConditionFilterKey; -import org.thingsboard.server.common.data.device.profile.AlarmConditionKeyType; -import org.thingsboard.server.common.data.device.profile.AlarmRule; +import org.thingsboard.server.common.data.alarm.rule.AlarmRule; +import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; +import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.debug.DebugSettings; import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileTransportConfiguration; -import org.thingsboard.server.common.data.device.profile.DeviceProfileAlarm; import org.thingsboard.server.common.data.device.profile.DeviceProfileData; import org.thingsboard.server.common.data.device.profile.DisabledDeviceProfileProvisionConfiguration; -import org.thingsboard.server.common.data.device.profile.SimpleAlarmConditionSpec; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; @@ -74,12 +82,7 @@ import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.mobile.app.MobileApp; import org.thingsboard.server.common.data.page.PageDataIterable; import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.common.data.query.BooleanFilterPredicate; -import org.thingsboard.server.common.data.query.DynamicValue; -import org.thingsboard.server.common.data.query.DynamicValueSourceType; import org.thingsboard.server.common.data.query.EntityKeyValueType; -import org.thingsboard.server.common.data.query.FilterPredicateValue; -import org.thingsboard.server.common.data.query.NumericFilterPredicate; import org.thingsboard.server.common.data.queue.ProcessingStrategy; import org.thingsboard.server.common.data.queue.ProcessingStrategyType; import org.thingsboard.server.common.data.queue.Queue; @@ -94,6 +97,7 @@ import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileCon import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfiguration; import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.device.DeviceConnectivityConfiguration; import org.thingsboard.server.dao.device.DeviceCredentialsService; @@ -117,7 +121,7 @@ import java.util.Arrays; import java.util.Base64; import java.util.Collections; import java.util.List; -import java.util.TreeMap; +import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -155,6 +159,7 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { private final MobileAppDao mobileAppDao; private final NotificationSettingsService notificationSettingsService; private final NotificationTargetService notificationTargetService; + private final CalculatedFieldService calculatedFieldService; @Autowired private BCryptPasswordEncoder passwordEncoder; @@ -306,8 +311,8 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { if (invalidSignKey) { log.warn("WARNING: {}. A new JWT Signing Key has been added automatically. " + - "You can change the JWT Signing Key using the Web UI: " + - "Navigate to \"System settings -> Security settings\" while logged in as a System Administrator.", warningMessage); + "You can change the JWT Signing Key using the Web UI: " + + "Navigate to \"System settings -> Security settings\" while logged in as a System Administrator.", warningMessage); jwtSettings.setTokenSigningKey(generateRandomKey()); jwtSettingsService.saveJwtSettings(jwtSettings); @@ -319,9 +324,9 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { .filter(mobileApp -> !validateKeyLength(mobileApp.getAppSecret())) .forEach(mobileApp -> { log.warn("WARNING: The App secret is shorter than 512 bits, which is a security risk. " + - "A new Application Secret has been added automatically for Mobile Application [{}]. " + - "You can change the Application Secret using the Web UI: " + - "Navigate to \"Security settings -> OAuth2 -> Mobile applications\" while logged in as a System Administrator.", mobileApp.getPkgName()); + "A new Application Secret has been added automatically for Mobile Application [{}]. " + + "You can change the Application Secret using the Web UI: " + + "Navigate to \"Security settings -> OAuth2 -> Mobile applications\" while logged in as a System Administrator.", mobileApp.getPkgName()); mobileApp.setAppSecret(generateRandomKey()); mobileAppDao.save(TenantId.SYS_TENANT_ID, mobileApp); }); @@ -372,11 +377,11 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { createDevice(demoTenant.getId(), customerB.getId(), defaultDeviceProfile.getId(), "Test Device B1", "B1_TEST_TOKEN", null); createDevice(demoTenant.getId(), customerC.getId(), defaultDeviceProfile.getId(), "Test Device C1", "C1_TEST_TOKEN", null); - createDevice(demoTenant.getId(), null, defaultDeviceProfile.getId(), "DHT11 Demo Device", "DHT11_DEMO_TOKEN", "Demo device that is used in sample " + - "applications that upload data from DHT11 temperature and humidity sensor"); + createDevice(demoTenant.getId(), null, defaultDeviceProfile.getId(), "DHT11 Demo Device", "DHT11_DEMO_TOKEN", + "Demo device that is used in sample applications that upload data from DHT11 temperature and humidity sensor"); - createDevice(demoTenant.getId(), null, defaultDeviceProfile.getId(), "Raspberry Pi Demo Device", "RASPBERRY_PI_DEMO_TOKEN", "Demo device that is used in " + - "Raspberry Pi GPIO control sample application"); + createDevice(demoTenant.getId(), null, defaultDeviceProfile.getId(), "Raspberry Pi Demo Device", "RASPBERRY_PI_DEMO_TOKEN", + "Demo device that is used in Raspberry Pi GPIO control sample application"); DeviceProfile thermostatDeviceProfile = new DeviceProfile(); thermostatDeviceProfile.setTenantId(demoTenant.getId()); @@ -398,110 +403,8 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { deviceProfileData.setProvisionConfiguration(provisionConfiguration); thermostatDeviceProfile.setProfileData(deviceProfileData); - DeviceProfileAlarm highTemperature = new DeviceProfileAlarm(); - highTemperature.setId("highTemperatureAlarmID"); - highTemperature.setAlarmType("High Temperature"); - AlarmRule temperatureRule = new AlarmRule(); - AlarmCondition temperatureCondition = new AlarmCondition(); - temperatureCondition.setSpec(new SimpleAlarmConditionSpec()); - - AlarmConditionFilter temperatureAlarmFlagAttributeFilter = new AlarmConditionFilter(); - temperatureAlarmFlagAttributeFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.ATTRIBUTE, "temperatureAlarmFlag")); - temperatureAlarmFlagAttributeFilter.setValueType(EntityKeyValueType.BOOLEAN); - BooleanFilterPredicate temperatureAlarmFlagAttributePredicate = new BooleanFilterPredicate(); - temperatureAlarmFlagAttributePredicate.setOperation(BooleanFilterPredicate.BooleanOperation.EQUAL); - temperatureAlarmFlagAttributePredicate.setValue(new FilterPredicateValue<>(Boolean.TRUE)); - temperatureAlarmFlagAttributeFilter.setPredicate(temperatureAlarmFlagAttributePredicate); - - AlarmConditionFilter temperatureTimeseriesFilter = new AlarmConditionFilter(); - temperatureTimeseriesFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.TIME_SERIES, "temperature")); - temperatureTimeseriesFilter.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate temperatureTimeseriesFilterPredicate = new NumericFilterPredicate(); - temperatureTimeseriesFilterPredicate.setOperation(NumericFilterPredicate.NumericOperation.GREATER); - FilterPredicateValue temperatureTimeseriesPredicateValue = - new FilterPredicateValue<>(25.0, null, - new DynamicValue<>(DynamicValueSourceType.CURRENT_DEVICE, "temperatureAlarmThreshold")); - temperatureTimeseriesFilterPredicate.setValue(temperatureTimeseriesPredicateValue); - temperatureTimeseriesFilter.setPredicate(temperatureTimeseriesFilterPredicate); - temperatureCondition.setCondition(Arrays.asList(temperatureAlarmFlagAttributeFilter, temperatureTimeseriesFilter)); - temperatureRule.setAlarmDetails("Current temperature = ${temperature}"); - temperatureRule.setCondition(temperatureCondition); - highTemperature.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.MAJOR, temperatureRule))); - - AlarmRule clearTemperatureRule = new AlarmRule(); - AlarmCondition clearTemperatureCondition = new AlarmCondition(); - clearTemperatureCondition.setSpec(new SimpleAlarmConditionSpec()); - - AlarmConditionFilter clearTemperatureTimeseriesFilter = new AlarmConditionFilter(); - clearTemperatureTimeseriesFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.TIME_SERIES, "temperature")); - clearTemperatureTimeseriesFilter.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate clearTemperatureTimeseriesFilterPredicate = new NumericFilterPredicate(); - clearTemperatureTimeseriesFilterPredicate.setOperation(NumericFilterPredicate.NumericOperation.LESS_OR_EQUAL); - FilterPredicateValue clearTemperatureTimeseriesPredicateValue = - new FilterPredicateValue<>(25.0, null, - new DynamicValue<>(DynamicValueSourceType.CURRENT_DEVICE, "temperatureAlarmThreshold")); - - clearTemperatureTimeseriesFilterPredicate.setValue(clearTemperatureTimeseriesPredicateValue); - clearTemperatureTimeseriesFilter.setPredicate(clearTemperatureTimeseriesFilterPredicate); - clearTemperatureCondition.setCondition(Collections.singletonList(clearTemperatureTimeseriesFilter)); - clearTemperatureRule.setCondition(clearTemperatureCondition); - clearTemperatureRule.setAlarmDetails("Current temperature = ${temperature}"); - highTemperature.setClearRule(clearTemperatureRule); - - DeviceProfileAlarm lowHumidity = new DeviceProfileAlarm(); - lowHumidity.setId("lowHumidityAlarmID"); - lowHumidity.setAlarmType("Low Humidity"); - AlarmRule humidityRule = new AlarmRule(); - AlarmCondition humidityCondition = new AlarmCondition(); - humidityCondition.setSpec(new SimpleAlarmConditionSpec()); - - AlarmConditionFilter humidityAlarmFlagAttributeFilter = new AlarmConditionFilter(); - humidityAlarmFlagAttributeFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.ATTRIBUTE, "humidityAlarmFlag")); - humidityAlarmFlagAttributeFilter.setValueType(EntityKeyValueType.BOOLEAN); - BooleanFilterPredicate humidityAlarmFlagAttributePredicate = new BooleanFilterPredicate(); - humidityAlarmFlagAttributePredicate.setOperation(BooleanFilterPredicate.BooleanOperation.EQUAL); - humidityAlarmFlagAttributePredicate.setValue(new FilterPredicateValue<>(Boolean.TRUE)); - humidityAlarmFlagAttributeFilter.setPredicate(humidityAlarmFlagAttributePredicate); - - AlarmConditionFilter humidityTimeseriesFilter = new AlarmConditionFilter(); - humidityTimeseriesFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.TIME_SERIES, "humidity")); - humidityTimeseriesFilter.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate humidityTimeseriesFilterPredicate = new NumericFilterPredicate(); - humidityTimeseriesFilterPredicate.setOperation(NumericFilterPredicate.NumericOperation.LESS); - FilterPredicateValue humidityTimeseriesPredicateValue = - new FilterPredicateValue<>(60.0, null, - new DynamicValue<>(DynamicValueSourceType.CURRENT_DEVICE, "humidityAlarmThreshold")); - humidityTimeseriesFilterPredicate.setValue(humidityTimeseriesPredicateValue); - humidityTimeseriesFilter.setPredicate(humidityTimeseriesFilterPredicate); - humidityCondition.setCondition(Arrays.asList(humidityAlarmFlagAttributeFilter, humidityTimeseriesFilter)); - - humidityRule.setCondition(humidityCondition); - humidityRule.setAlarmDetails("Current humidity = ${humidity}"); - lowHumidity.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.MINOR, humidityRule))); - - AlarmRule clearHumidityRule = new AlarmRule(); - AlarmCondition clearHumidityCondition = new AlarmCondition(); - clearHumidityCondition.setSpec(new SimpleAlarmConditionSpec()); - - AlarmConditionFilter clearHumidityTimeseriesFilter = new AlarmConditionFilter(); - clearHumidityTimeseriesFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.TIME_SERIES, "humidity")); - clearHumidityTimeseriesFilter.setValueType(EntityKeyValueType.NUMERIC); - NumericFilterPredicate clearHumidityTimeseriesFilterPredicate = new NumericFilterPredicate(); - clearHumidityTimeseriesFilterPredicate.setOperation(NumericFilterPredicate.NumericOperation.GREATER_OR_EQUAL); - FilterPredicateValue clearHumidityTimeseriesPredicateValue = - new FilterPredicateValue<>(60.0, null, - new DynamicValue<>(DynamicValueSourceType.CURRENT_DEVICE, "humidityAlarmThreshold")); - - clearHumidityTimeseriesFilterPredicate.setValue(clearHumidityTimeseriesPredicateValue); - clearHumidityTimeseriesFilter.setPredicate(clearHumidityTimeseriesFilterPredicate); - clearHumidityCondition.setCondition(Collections.singletonList(clearHumidityTimeseriesFilter)); - clearHumidityRule.setCondition(clearHumidityCondition); - clearHumidityRule.setAlarmDetails("Current humidity = ${humidity}"); - lowHumidity.setClearRule(clearHumidityRule); - - deviceProfileData.setAlarms(Arrays.asList(highTemperature, lowHumidity)); - DeviceProfile savedThermostatDeviceProfile = deviceProfileService.saveDeviceProfile(thermostatDeviceProfile); + createAlarmRules(demoTenant.getId(), savedThermostatDeviceProfile.getId()); DeviceId t1Id = createDevice(demoTenant.getId(), null, savedThermostatDeviceProfile.getId(), "Thermostat T1", "T1_TEST_TOKEN", "Demo device for Thermostats dashboard").getId(); DeviceId t2Id = createDevice(demoTenant.getId(), null, savedThermostatDeviceProfile.getId(), "Thermostat T2", "T2_TEST_TOKEN", "Demo device for Thermostats dashboard").getId(); @@ -526,6 +429,136 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { installScripts.createDefaultTenantDashboards(demoTenant.getId(), null); } + private void createAlarmRules(TenantId tenantId, DeviceProfileId deviceProfileId) { + CalculatedField highTemperature = new CalculatedField(); + highTemperature.setName("High Temperature"); + highTemperature.setType(CalculatedFieldType.ALARM); + highTemperature.setTenantId(tenantId); + highTemperature.setEntityId(deviceProfileId); + highTemperature.setDebugSettings(DebugSettings.all()); + AlarmCalculatedFieldConfiguration highTemperatureConfig = new AlarmCalculatedFieldConfiguration(); + highTemperature.setConfiguration(highTemperatureConfig); + + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + Argument temperatureThresholdArgument = new Argument(); + temperatureThresholdArgument.setRefEntityKey(new ReferencedEntityKey("temperatureAlarmThreshold", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + temperatureThresholdArgument.setDefaultValue("25"); + Argument temperatureAlarmFlagArgument = new Argument(); + temperatureAlarmFlagArgument.setRefEntityKey(new ReferencedEntityKey("temperatureAlarmFlag", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + highTemperatureConfig.setArguments(Map.of( + "temperature", temperatureArgument, + "temperatureAlarmThreshold", temperatureThresholdArgument, + "temperatureAlarmFlag", temperatureAlarmFlagArgument + )); + + AlarmRule temperatureRule = new AlarmRule(); + SimpleAlarmCondition temperatureCondition = new SimpleAlarmCondition(); + + AlarmConditionFilter temperatureAlarmFlagFilter = new AlarmConditionFilter(); + temperatureAlarmFlagFilter.setArgument("temperatureAlarmFlag"); + temperatureAlarmFlagFilter.setValueType(EntityKeyValueType.BOOLEAN); + BooleanFilterPredicate temperatureAlarmFlagAttributePredicate = new BooleanFilterPredicate(); + temperatureAlarmFlagAttributePredicate.setOperation(BooleanFilterPredicate.BooleanOperation.EQUAL); + temperatureAlarmFlagAttributePredicate.setValue(new AlarmConditionValue<>(Boolean.TRUE, null)); + temperatureAlarmFlagFilter.setPredicates(List.of(temperatureAlarmFlagAttributePredicate)); + + AlarmConditionFilter temperatureFilter = new AlarmConditionFilter(); + temperatureFilter.setArgument("temperature"); + temperatureFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate temperatureFilterPredicate = new NumericFilterPredicate(); + temperatureFilterPredicate.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + temperatureFilterPredicate.setValue(new AlarmConditionValue<>(null, "temperatureAlarmThreshold")); + temperatureFilter.setPredicates(List.of(temperatureFilterPredicate)); + temperatureCondition.setExpression(new SimpleAlarmConditionExpression(List.of(temperatureAlarmFlagFilter, temperatureFilter), ComplexOperation.AND)); + temperatureRule.setCondition(temperatureCondition); + temperatureRule.setAlarmDetails("Current temperature = ${temperature}"); + highTemperatureConfig.setCreateRules(Map.of( + AlarmSeverity.MAJOR, temperatureRule + )); + + AlarmRule clearTemperatureRule = new AlarmRule(); + SimpleAlarmCondition clearTemperatureCondition = new SimpleAlarmCondition(); + + AlarmConditionFilter clearTemperatureFilter = new AlarmConditionFilter(); + clearTemperatureFilter.setArgument("temperature"); + clearTemperatureFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate clearTemperatureFilterPredicate = new NumericFilterPredicate(); + clearTemperatureFilterPredicate.setOperation(NumericFilterPredicate.NumericOperation.LESS_OR_EQUAL); + clearTemperatureFilterPredicate.setValue(new AlarmConditionValue<>(null, "temperatureAlarmThreshold")); + clearTemperatureFilter.setPredicates(List.of(clearTemperatureFilterPredicate)); + clearTemperatureCondition.setExpression(new SimpleAlarmConditionExpression(List.of(clearTemperatureFilter), ComplexOperation.AND)); + clearTemperatureRule.setCondition(clearTemperatureCondition); + clearTemperatureRule.setAlarmDetails("Current temperature = ${temperature}"); + highTemperatureConfig.setClearRule(clearTemperatureRule); + + calculatedFieldService.save(highTemperature); + + CalculatedField lowHumidity = new CalculatedField(); + lowHumidity.setName("Low Humidity"); + lowHumidity.setType(CalculatedFieldType.ALARM); + lowHumidity.setTenantId(tenantId); + lowHumidity.setEntityId(deviceProfileId); + lowHumidity.setDebugSettings(DebugSettings.all()); + AlarmCalculatedFieldConfiguration lowHumidityConfig = new AlarmCalculatedFieldConfiguration(); + lowHumidity.setConfiguration(lowHumidityConfig); + + Argument humidityArgument = new Argument(); + humidityArgument.setRefEntityKey(new ReferencedEntityKey("humidity", ArgumentType.TS_LATEST, null)); + Argument humidityThresholdArgument = new Argument(); + humidityThresholdArgument.setRefEntityKey(new ReferencedEntityKey("humidityAlarmThreshold", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + humidityThresholdArgument.setDefaultValue("60"); + Argument humidityAlarmFlagArgument = new Argument(); + humidityAlarmFlagArgument.setRefEntityKey(new ReferencedEntityKey("humidityAlarmFlag", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + lowHumidityConfig.setArguments(Map.of( + "humidity", humidityArgument, + "humidityAlarmThreshold", humidityThresholdArgument, + "humidityAlarmFlag", humidityAlarmFlagArgument + )); + + AlarmRule humidityRule = new AlarmRule(); + SimpleAlarmCondition humidityCondition = new SimpleAlarmCondition(); + + AlarmConditionFilter humidityAlarmFlagAttributeFilter = new AlarmConditionFilter(); + humidityAlarmFlagAttributeFilter.setArgument("humidityAlarmFlag"); + humidityAlarmFlagAttributeFilter.setValueType(EntityKeyValueType.BOOLEAN); + BooleanFilterPredicate humidityAlarmFlagPredicate = new BooleanFilterPredicate(); + humidityAlarmFlagPredicate.setOperation(BooleanFilterPredicate.BooleanOperation.EQUAL); + humidityAlarmFlagPredicate.setValue(new AlarmConditionValue<>(Boolean.TRUE, null)); + humidityAlarmFlagAttributeFilter.setPredicates(List.of(humidityAlarmFlagPredicate)); + + AlarmConditionFilter humidityFilter = new AlarmConditionFilter(); + humidityFilter.setArgument("humidity"); + humidityFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate humidityFilterPredicate = new NumericFilterPredicate(); + humidityFilterPredicate.setOperation(NumericFilterPredicate.NumericOperation.LESS); + humidityFilterPredicate.setValue(new AlarmConditionValue<>(null, "humidityAlarmThreshold")); + humidityFilter.setPredicates(List.of(humidityFilterPredicate)); + humidityCondition.setExpression(new SimpleAlarmConditionExpression(List.of(humidityAlarmFlagAttributeFilter, humidityFilter), ComplexOperation.AND)); + humidityRule.setCondition(humidityCondition); + humidityRule.setAlarmDetails("Current humidity = ${humidity}"); + lowHumidityConfig.setCreateRules(Map.of( + AlarmSeverity.MINOR, humidityRule + )); + + AlarmRule clearHumidityRule = new AlarmRule(); + SimpleAlarmCondition clearHumidityCondition = new SimpleAlarmCondition(); + + AlarmConditionFilter clearHumidityFilter = new AlarmConditionFilter(); + clearHumidityFilter.setArgument("humidity"); + clearHumidityFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate clearHumidityFilterPredicate = new NumericFilterPredicate(); + clearHumidityFilterPredicate.setOperation(NumericFilterPredicate.NumericOperation.GREATER_OR_EQUAL); + clearHumidityFilterPredicate.setValue(new AlarmConditionValue<>(null, "humidityAlarmThreshold")); + clearHumidityFilter.setPredicates(List.of(clearHumidityFilterPredicate)); + clearHumidityCondition.setExpression(new SimpleAlarmConditionExpression(List.of(clearHumidityFilter), ComplexOperation.AND)); + clearHumidityRule.setCondition(clearHumidityCondition); + clearHumidityRule.setAlarmDetails("Current humidity = ${humidity}"); + lowHumidityConfig.setClearRule(clearHumidityRule); + + calculatedFieldService.save(lowHumidity); + } + @Override public void loadSystemWidgets() throws Exception { installScripts.loadSystemWidgets(); @@ -609,6 +642,7 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { public void onFailure(Throwable t) { log.warn("[{}] Failed to update attribute [{}] with value [{}]", deviceId, key, value, t); } + } private void addTsCallback(ListenableFuture saveFuture, final FutureCallback callback) { 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 4e44517ca0..f9b5c282a8 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 @@ -65,9 +65,6 @@ import java.util.stream.Stream; import static org.thingsboard.server.utils.LwM2mObjectModelUtils.toLwm2mResource; -/** - * Created by ashvayka on 18.04.18. - */ @Component @Slf4j public class InstallScripts { @@ -134,6 +131,10 @@ public class InstallScripts { return Paths.get(getDataDir(), JSON_DIR, EDGE_DIR, RULE_CHAINS_DIR); } + public Path getWidgetTypesDir() { + return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR); + } + public String getDataDir() { if (!StringUtils.isEmpty(dataDir)) { if (!Paths.get(this.dataDir).toFile().isDirectory()) { @@ -237,7 +238,7 @@ public class InstallScripts { } ); } - Path widgetTypesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR); + Path widgetTypesDir = getWidgetTypesDir(); if (Files.exists(widgetTypesDir)) { try (Stream dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(JSON_EXT))) { dirStream.forEach( diff --git a/application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java b/application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java index 4422d952a5..8b7218a981 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java +++ b/application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java @@ -19,14 +19,17 @@ import lombok.RequiredArgsConstructor; import org.springframework.boot.info.BuildProperties; import org.springframework.stereotype.Component; +import java.util.Optional; + @Component @RequiredArgsConstructor public class ProjectInfo { - private final BuildProperties buildProperties; + private final Optional buildProperties; public String getProjectVersion() { - return buildProperties.getVersion().replaceAll("[^\\d.]", ""); + return buildProperties.orElseThrow(() -> new IllegalStateException("Build properties are missing. Please rebuild the project with maven")) + .getVersion().replaceAll("[^\\d.]", ""); } public String getProductType() { diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmAssignmentTriggerProcessor.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmAssignmentTriggerProcessor.java index ca3667b6ef..f9646409c6 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmAssignmentTriggerProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmAssignmentTriggerProcessor.java @@ -62,6 +62,7 @@ public class AlarmAssignmentTriggerProcessor implements NotificationRuleTriggerP .alarmType(alarmInfo.getType()) .alarmOriginator(alarmInfo.getOriginator()) .alarmOriginatorName(alarmInfo.getOriginatorName()) + .alarmOriginatorLabel(alarmInfo.getOriginatorLabel()) .alarmSeverity(alarmInfo.getSeverity()) .alarmStatus(alarmInfo.getStatus()) .alarmCustomerId(alarmInfo.getCustomerId()) diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmCommentTriggerProcessor.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmCommentTriggerProcessor.java index 8cca98f7fd..a1df9b3e58 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmCommentTriggerProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmCommentTriggerProcessor.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.alarm.AlarmCommentType; import org.thingsboard.server.common.data.alarm.AlarmInfo; import org.thingsboard.server.common.data.alarm.AlarmStatusFilter; import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.NameLabelAndCustomerDetails; import org.thingsboard.server.common.data.notification.info.AlarmCommentNotificationInfo; import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo; import org.thingsboard.server.common.data.notification.rule.trigger.AlarmCommentTrigger; @@ -57,11 +58,14 @@ public class AlarmCommentTriggerProcessor implements NotificationRuleTriggerProc @Override public RuleOriginatedNotificationInfo constructNotificationInfo(AlarmCommentTrigger trigger) { Alarm alarm = trigger.getAlarm(); - String originatorName; + String originatorName, originatorLabel; if (alarm instanceof AlarmInfo) { originatorName = ((AlarmInfo) alarm).getOriginatorName(); + originatorLabel = ((AlarmInfo) alarm).getOriginatorLabel(); } else { - originatorName = entityService.fetchEntityName(trigger.getTenantId(), alarm.getOriginator()).orElse(""); + var infoOpt = entityService.fetchNameLabelAndCustomerDetails(trigger.getTenantId(), alarm.getOriginator()); + originatorName = infoOpt.map(NameLabelAndCustomerDetails::getName).orElse(null); + originatorLabel = infoOpt.map(NameLabelAndCustomerDetails::getLabel).orElse(null); } return AlarmCommentNotificationInfo.builder() .comment(trigger.getComment().getComment().get("text").asText()) @@ -73,6 +77,7 @@ public class AlarmCommentTriggerProcessor implements NotificationRuleTriggerProc .alarmType(alarm.getType()) .alarmOriginator(alarm.getOriginator()) .alarmOriginatorName(originatorName) + .alarmOriginatorLabel(originatorLabel) .alarmSeverity(alarm.getSeverity()) .alarmStatus(alarm.getStatus()) .alarmCustomerId(alarm.getCustomerId()) diff --git a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmTriggerProcessor.java b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmTriggerProcessor.java index 7d6959dc4f..5d4be59b97 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmTriggerProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/rule/trigger/AlarmTriggerProcessor.java @@ -15,7 +15,9 @@ */ package org.thingsboard.server.service.notification.rule.trigger; +import com.fasterxml.jackson.databind.JsonNode; import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmApiCallResult; import org.thingsboard.server.common.data.alarm.AlarmInfo; @@ -28,6 +30,9 @@ import org.thingsboard.server.common.data.notification.rule.trigger.config.Alarm import org.thingsboard.server.common.data.notification.rule.trigger.config.AlarmNotificationRuleTriggerConfig.ClearRule; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; +import java.util.HashMap; +import java.util.Map; + import static org.apache.commons.collections4.CollectionUtils.isNotEmpty; import static org.thingsboard.server.common.data.util.CollectionsUtil.emptyOrContains; @@ -106,15 +111,27 @@ public class AlarmTriggerProcessor implements NotificationRuleTriggerProcessor toDetailsTemplateMap(JsonNode details) { + Map infoMap = JacksonUtil.toFlatMap(details); + Map result = new HashMap<>(); + for (Map.Entry entry : infoMap.entrySet()) { + String key = "details." + entry.getKey(); + result.put(key, entry.getValue()); + } + return result; + } + @Override public NotificationRuleTriggerType getTriggerType() { return NotificationRuleTriggerType.ALARM; diff --git a/application/src/main/java/org/thingsboard/server/service/ota/DefaultOtaPackageStateService.java b/application/src/main/java/org/thingsboard/server/service/ota/DefaultOtaPackageStateService.java index 4d9e145711..0b4d3bec6f 100644 --- a/application/src/main/java/org/thingsboard/server/service/ota/DefaultOtaPackageStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/ota/DefaultOtaPackageStateService.java @@ -328,7 +328,7 @@ public class DefaultOtaPackageStateService implements OtaPackageStateService { attributes.add(new BaseAttributeKvEntry(ts, new LongDataEntry(getAttributeKey(otaPackageType, SIZE), otaPackage.getDataSize()))); } - if (otaPackage.getChecksumAlgorithm() != null) { + if (otaPackage.getChecksumAlgorithm() == null) { attrToRemove.add(getAttributeKey(otaPackageType, CHECKSUM_ALGORITHM)); } else { attributes.add(new BaseAttributeKvEntry(ts, new StringDataEntry(getAttributeKey(otaPackageType, CHECKSUM_ALGORITHM), otaPackage.getChecksumAlgorithm().name()))); diff --git a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java index 45a73072f5..f39769526a 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java @@ -238,7 +238,7 @@ public class DefaultEntityQueryService implements EntityQueryService { entitiesSortOrder = sortOrder; } EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); - return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters()); + return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters()); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java index 8d4ab25578..0449d116c8 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java @@ -22,6 +22,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.calculatedField.CalculatedFieldEntityActionEventMsg; import org.thingsboard.server.actors.calculatedField.CalculatedFieldLinkedTelemetryMsg; import org.thingsboard.server.actors.calculatedField.CalculatedFieldTelemetryMsg; import org.thingsboard.server.common.data.DataConstants; @@ -35,6 +36,7 @@ import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; @@ -83,6 +85,7 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa ActorSystemContext actorContext, TbDeviceProfileCache deviceProfileCache, TbAssetProfileCache assetProfileCache, + TbResourceDataCache tbResourceDataCache, TbTenantProfileCache tenantProfileCache, TbApiUsageStateService apiUsageStateService, PartitionService partitionService, @@ -90,7 +93,7 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa JwtSettingsService jwtSettingsService, CalculatedFieldCache calculatedFieldCache, CalculatedFieldStateService stateService) { - super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, calculatedFieldCache, apiUsageStateService, partitionService, + super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService); this.queueFactory = tbQueueFactory; this.stateService = stateService; @@ -158,12 +161,7 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa try { ToCalculatedFieldMsg toCfMsg = msg.getValue(); pendingMsgHolder.setMsg(toCfMsg); - if (toCfMsg.hasTelemetryMsg()) { - log.trace("[{}] Forwarding regular telemetry message for processing {}", id, toCfMsg.getTelemetryMsg()); - forwardToActorSystem(toCfMsg.getTelemetryMsg(), callback); - } else if (toCfMsg.hasLinkedTelemetryMsg()) { - forwardToActorSystem(toCfMsg.getLinkedTelemetryMsg(), callback); - } + processMsg(toCfMsg, id, callback); } catch (Throwable e) { log.warn("[{}] Failed to process message: {}", id, msg, e); callback.onFailure(e); @@ -181,6 +179,17 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa consumer.commit(); } + private void processMsg(ToCalculatedFieldMsg toCfMsg, UUID id, TbCallback callback) { + if (toCfMsg.hasTelemetryMsg()) { + log.trace("[{}] Forwarding regular telemetry message for processing {}", id, toCfMsg.getTelemetryMsg()); + forwardToActorSystem(toCfMsg.getTelemetryMsg(), callback); + } else if (toCfMsg.hasLinkedTelemetryMsg()) { + forwardToActorSystem(toCfMsg.getLinkedTelemetryMsg(), callback); + } else if (toCfMsg.hasEventMsg()) { + actorContext.tell(CalculatedFieldEntityActionEventMsg.fromProto(toCfMsg.getEventMsg(), callback)); + } + } + @Override protected ServiceType getServiceType() { return ServiceType.TB_RULE_ENGINE; diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java index 265f14c4e2..b54a7fb83a 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java @@ -26,6 +26,7 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cache.TbTransactionalCache; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; @@ -48,12 +49,14 @@ import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.ToDeviceActorNotificationMsg; import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; @@ -435,8 +438,9 @@ public class DefaultTbClusterService implements TbClusterService { @Override public void onResourceChange(TbResourceInfo resource, TbQueueCallback callback) { + TenantId tenantId = resource.getTenantId(); + TbResourceId resourceId = resource.getId(); if (resource.getResourceType() == ResourceType.LWM2M_MODEL) { - TenantId tenantId = resource.getTenantId(); log.trace("[{}][{}][{}] Processing change resource", tenantId, resource.getResourceType(), resource.getResourceKey()); ResourceUpdateMsg resourceUpdateMsg = ResourceUpdateMsg.newBuilder() .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) @@ -447,6 +451,7 @@ public class DefaultTbClusterService implements TbClusterService { ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceUpdateMsg(resourceUpdateMsg).build(); broadcast(transportMsg, DataConstants.LWM2M_TRANSPORT_NAME, callback); } + broadcastEntityStateChangeEvent(tenantId, resourceId, ComponentLifecycleEvent.UPDATED); } @Override @@ -462,6 +467,18 @@ public class DefaultTbClusterService implements TbClusterService { ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceDeleteMsg(resourceDeleteMsg).build(); broadcast(transportMsg, DataConstants.LWM2M_TRANSPORT_NAME, callback); } + broadcastEntityStateChangeEvent(resource.getTenantId(), resource.getId(), ComponentLifecycleEvent.DELETED); + } + + @Override + public void onCustomerUpdated(Customer customer, Customer oldCustomer) { + ComponentLifecycleMsg msg = ComponentLifecycleMsg.builder() + .tenantId(customer.getTenantId()) + .entityId(customer.getId()) + .event(oldCustomer == null ? ComponentLifecycleEvent.CREATED : ComponentLifecycleEvent.UPDATED) + .ownerChanged(false) // for compatibility with PE + .build(); + broadcast(msg); } private void broadcastEntityChangeToTransport(TenantId tenantId, EntityId entityid, T entity, TbQueueCallback callback) { @@ -592,7 +609,9 @@ public class DefaultTbClusterService implements TbClusterService { EntityType.TENANT_PROFILE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE, - EntityType.JOB) + EntityType.JOB, + EntityType.TB_RESOURCE, + EntityType.CUSTOMER) || (entityType == EntityType.ASSET && msg.getEvent() == ComponentLifecycleEvent.UPDATED) || (entityType == EntityType.DEVICE && msg.getEvent() == ComponentLifecycleEvent.UPDATED) ) { @@ -668,6 +687,7 @@ public class DefaultTbClusterService implements TbClusterService { } msg.event(ComponentLifecycleEvent.UPDATED) .oldProfileId(old.getDeviceProfileId()) + .ownerChanged(!entity.getOwnerId().equals(old.getOwnerId())) .oldName(old.getName()); } broadcast(msg.build()); @@ -688,6 +708,7 @@ public class DefaultTbClusterService implements TbClusterService { } else { msg.event(ComponentLifecycleEvent.UPDATED) .oldProfileId(old.getAssetProfileId()) + .ownerChanged(!entity.getOwnerId().equals(old.getOwnerId())) .oldName(old.getName()); } broadcast(msg.build()); @@ -703,6 +724,28 @@ public class DefaultTbClusterService implements TbClusterService { broadcastEntityStateChangeEvent(calculatedField.getTenantId(), calculatedField.getId(), ComponentLifecycleEvent.DELETED); } + @Override + public void onRelationUpdated(TenantId tenantId, EntityRelation entityRelation, TbQueueCallback callback) { + ComponentLifecycleMsg msg = ComponentLifecycleMsg.builder() + .tenantId(tenantId) + .entityId(entityRelation.getFrom()) + .event(ComponentLifecycleEvent.RELATION_UPDATED) + .info(JacksonUtil.valueToTree(entityRelation)) + .build(); + broadcast(msg); + } + + @Override + public void onRelationDeleted(TenantId tenantId, EntityRelation entityRelation, TbQueueCallback callback) { + ComponentLifecycleMsg msg = ComponentLifecycleMsg.builder() + .tenantId(tenantId) + .entityId(entityRelation.getFrom()) + .event(ComponentLifecycleEvent.RELATION_DELETED) + .info(JacksonUtil.valueToTree(entityRelation)) + .build(); + broadcast(msg); + } + @Override public void sendNotificationMsgToEdge(TenantId tenantId, EdgeId edgeId, EntityId entityId, String body, EdgeEventType type, EdgeEventActionType action, EdgeId originatorEdgeId) { if (!edgesEnabled) { 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 f0b1a4d7d2..9ab5a062eb 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 @@ -55,6 +55,7 @@ import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.common.util.KvProtoUtil; import org.thingsboard.server.common.util.ProtoUtils; import org.thingsboard.server.dao.resource.ImageCacheKey; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.DeviceStateServiceMsgProto; @@ -176,10 +177,11 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService> msgs, TbQueueConsumer> consumer, - Object consumerKey, + ConsumerKey consumerKey, Queue queue) throws Exception { TbRuleEngineSubmitStrategy submitStrategy = getSubmitStrategy(queue); TbRuleEngineProcessingStrategy ackStrategy = getProcessingStrategy(queue); diff --git a/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java b/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java index 634190c1d7..28ec9af4b2 100644 --- a/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java +++ b/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java @@ -102,7 +102,6 @@ public class DefaultTbResourceService extends AbstractTbEntityService implements if (result.isSuccess()) { logEntityActionService.logEntityAction(tenantId, resourceId, tbResource, actionType, user, resourceId.toString()); } - return result; } catch (Exception e) { logEntityActionService.logEntityAction(tenantId, emptyId(EntityType.TB_RESOURCE), diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java index 27ed2996d1..ff39038453 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java @@ -18,8 +18,10 @@ package org.thingsboard.server.service.security.auth; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.AuthenticationException; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -32,6 +34,10 @@ public class AuthExceptionHandler extends OncePerRequestFilter { private final ThingsboardErrorResponseHandler errorResponseHandler; + @Value("${server.log_controller_error_stack_trace}") + @Getter + private boolean logControllerErrorStackTrace; + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { try { @@ -39,8 +45,15 @@ public class AuthExceptionHandler extends OncePerRequestFilter { } catch (AuthenticationException e) { throw e; } catch (Exception e) { + log(e); errorResponseHandler.handle(e, response); } } + private void log(Exception e) { + if (logControllerErrorStackTrace) { + log.error("Auth error", e); + } + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/MfaConfigurationToken.java b/application/src/main/java/org/thingsboard/server/service/security/auth/MfaConfigurationToken.java new file mode 100644 index 0000000000..b52404bac2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/MfaConfigurationToken.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.security.auth; + +import org.thingsboard.server.service.security.model.SecurityUser; + +public class MfaConfigurationToken extends AbstractJwtAuthenticationToken { + public MfaConfigurationToken(SecurityUser securityUser) { + super(securityUser); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java index 0bf314a145..325203314f 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.limit.LimitedApi; +import org.thingsboard.server.common.data.notification.targets.platform.SystemLevelUsersFilter; import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig; @@ -61,12 +62,25 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { private static final ThingsboardException TOO_MANY_REQUESTS_ERROR = new ThingsboardException("Too many requests", ThingsboardErrorCode.TOO_MANY_REQUESTS); @Override - public boolean isTwoFaEnabled(TenantId tenantId, UserId userId) { - return configManager.getAccountTwoFaSettings(tenantId, userId) + public boolean isTwoFaEnabled(TenantId tenantId, User user) { + return configManager.getAccountTwoFaSettings(tenantId, user) .map(settings -> !settings.getConfigs().isEmpty()) .orElse(false); } + @Override + public boolean isEnforceTwoFaEnabled(TenantId tenantId, User user) { + SystemLevelUsersFilter enforcedUsersFilter = configManager.getPlatformTwoFaSettings(TenantId.SYS_TENANT_ID, true) + .filter(PlatformTwoFaSettings::isEnforceTwoFa) + .map(PlatformTwoFaSettings::getEnforcedUsersFilter) + .orElse(null); + if (enforcedUsersFilter == null) { + return false; + } + + return userService.matchesFilter(tenantId, enforcedUsersFilter, user); + } + @Override public void checkProvider(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException { getTwoFaProvider(providerType).check(tenantId); @@ -75,7 +89,7 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { @Override public void prepareVerificationCode(SecurityUser user, TwoFaProviderType providerType, boolean checkLimits) throws Exception { - TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType) + TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user, providerType) .orElseThrow(() -> ACCOUNT_NOT_CONFIGURED_ERROR); prepareVerificationCode(user, accountConfig, checkLimits); } @@ -104,7 +118,7 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { @Override public boolean checkVerificationCode(SecurityUser user, TwoFaProviderType providerType, String verificationCode, boolean checkLimits) throws ThingsboardException { - TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType) + TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user, providerType) .orElseThrow(() -> ACCOUNT_NOT_CONFIGURED_ERROR); return checkVerificationCode(user, verificationCode, accountConfig, checkLimits); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java index e85916db2d..62fdb973d2 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java @@ -18,17 +18,17 @@ package org.thingsboard.server.service.security.auth.mfa; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; import org.thingsboard.server.service.security.model.SecurityUser; public interface TwoFactorAuthService { - boolean isTwoFaEnabled(TenantId tenantId, UserId userId); + boolean isTwoFaEnabled(TenantId tenantId, User user); - void checkProvider(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException; + boolean isEnforceTwoFaEnabled(TenantId tenantId, User user); + void checkProvider(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException; void prepareVerificationCode(SecurityUser user, TwoFaProviderType providerType, boolean checkLimits) throws Exception; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java index 60dc9bd6f2..ad04d3e962 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java @@ -21,15 +21,16 @@ import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.UserAuthSettings; import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings; import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.ConstraintValidator; import org.thingsboard.server.dao.settings.AdminSettingsDao; import org.thingsboard.server.dao.settings.AdminSettingsService; @@ -55,9 +56,9 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { @Override - public Optional getAccountTwoFaSettings(TenantId tenantId, UserId userId) { + public Optional getAccountTwoFaSettings(TenantId tenantId, User user) { PlatformTwoFaSettings platformTwoFaSettings = getPlatformTwoFaSettings(tenantId, true).orElse(null); - return Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) + return Optional.ofNullable(userAuthSettingsDao.findByUserId(user.getId())) .map(userAuthSettings -> { AccountTwoFaSettings twoFaSettings = userAuthSettings.getTwoFaSettings(); if (twoFaSettings == null) return null; @@ -79,17 +80,22 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { } if (updateNeeded) { - twoFaSettings = saveAccountTwoFaSettings(tenantId, userId, twoFaSettings); + twoFaSettings = saveAccountTwoFaSettings(tenantId, user, twoFaSettings); } return twoFaSettings; }); } - protected AccountTwoFaSettings saveAccountTwoFaSettings(TenantId tenantId, UserId userId, AccountTwoFaSettings settings) { - UserAuthSettings userAuthSettings = Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) + protected AccountTwoFaSettings saveAccountTwoFaSettings(TenantId tenantId, User user, AccountTwoFaSettings settings) { + if (settings.getConfigs().isEmpty()) { + if (twoFactorAuthService.isEnforceTwoFaEnabled(tenantId, user)) { + throw new DataValidationException("At least one 2FA provider is required"); + } + } + UserAuthSettings userAuthSettings = Optional.ofNullable(userAuthSettingsDao.findByUserId(user.getId())) .orElseGet(() -> { UserAuthSettings newUserAuthSettings = new UserAuthSettings(); - newUserAuthSettings.setUserId(userId); + newUserAuthSettings.setUserId(user.getId()); return newUserAuthSettings; }); userAuthSettings.setTwoFaSettings(settings); @@ -101,18 +107,18 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { @Override - public Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType) { - return getAccountTwoFaSettings(tenantId, userId) + public Optional getTwoFaAccountConfig(TenantId tenantId, User user, TwoFaProviderType providerType) { + return getAccountTwoFaSettings(tenantId, user) .map(AccountTwoFaSettings::getConfigs) .flatMap(configs -> Optional.ofNullable(configs.get(providerType))); } @Override - public AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaAccountConfig accountConfig) { + public AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, User user, TwoFaAccountConfig accountConfig) { getTwoFaProviderConfig(tenantId, accountConfig.getProviderType()) .orElseThrow(() -> new IllegalArgumentException("2FA provider is not configured")); - AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, userId).orElseGet(() -> { + AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, user).orElseGet(() -> { AccountTwoFaSettings newSettings = new AccountTwoFaSettings(); newSettings.setConfigs(new LinkedHashMap<>()); return newSettings; @@ -128,12 +134,12 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { if (configs.values().stream().noneMatch(TwoFaAccountConfig::isUseByDefault)) { configs.values().stream().findFirst().ifPresent(config -> config.setUseByDefault(true)); } - return saveAccountTwoFaSettings(tenantId, userId, settings); + return saveAccountTwoFaSettings(tenantId, user, settings); } @Override - public AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType) { - AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, userId) + public AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, User user, TwoFaProviderType providerType) { + AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, user) .orElseThrow(() -> new IllegalArgumentException("2FA not configured")); settings.getConfigs().remove(providerType); if (settings.getConfigs().size() == 1) { @@ -145,7 +151,7 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { .min(Comparator.comparing(TwoFaAccountConfig::getProviderType)) .ifPresent(config -> config.setUseByDefault(true)); } - return saveAccountTwoFaSettings(tenantId, userId, settings); + return saveAccountTwoFaSettings(tenantId, user, settings); } @@ -166,6 +172,19 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { for (TwoFaProviderConfig providerConfig : twoFactorAuthSettings.getProviders()) { twoFactorAuthService.checkProvider(tenantId, providerConfig.getProviderType()); } + if (tenantId.isSysTenantId()) { + if (twoFactorAuthSettings.isEnforceTwoFa()) { + if (twoFactorAuthSettings.getProviders().isEmpty()) { + throw new DataValidationException("At least one 2FA provider is required if enforcing is enabled"); + } + if (twoFactorAuthSettings.getEnforcedUsersFilter() == null) { + throw new DataValidationException("Users filter to enforce 2FA for is required"); + } + } + } else { + twoFactorAuthSettings.setEnforceTwoFa(false); + twoFactorAuthSettings.setEnforcedUsersFilter(null); + } AdminSettings settings = Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, TWO_FACTOR_AUTH_SETTINGS_KEY)) .orElseGet(() -> { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java index 92ac638298..e0ffd6e510 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java @@ -15,9 +15,9 @@ */ package org.thingsboard.server.service.security.auth.mfa.config; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings; import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig; @@ -27,14 +27,14 @@ import java.util.Optional; public interface TwoFaConfigManager { - Optional getAccountTwoFaSettings(TenantId tenantId, UserId userId); + Optional getAccountTwoFaSettings(TenantId tenantId, User user); - Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType); + Optional getTwoFaAccountConfig(TenantId tenantId, User user, TwoFaProviderType providerType); - AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaAccountConfig accountConfig); + AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, User user, TwoFaAccountConfig accountConfig); - AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType); + AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, User user, TwoFaProviderType providerType); Optional getPlatformTwoFaSettings(TenantId tenantId, boolean sysadminSettingsAsDefault); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/BackupCodeTwoFaProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/BackupCodeTwoFaProvider.java index 745b651408..672db5dddd 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/BackupCodeTwoFaProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/BackupCodeTwoFaProvider.java @@ -58,7 +58,7 @@ public class BackupCodeTwoFaProvider implements TwoFaProvider Optional.ofNullable(settings.getTotalAllowedTimeForVerification()) - .filter(time -> time > 0)) - .orElse((int) TimeUnit.MINUTES.toSeconds(30)); - tokenPair.setToken(tokenFactory.createPreVerificationToken(securityUser, preVerificationTokenLifetime).getToken()); - tokenPair.setRefreshToken(null); - tokenPair.setScope(Authority.PRE_VERIFICATION_TOKEN); + tokenPair = createMfaTokenPair(securityUser, Authority.PRE_VERIFICATION_TOKEN); + } else if (authentication instanceof MfaConfigurationToken) { + tokenPair = createMfaTokenPair(securityUser, Authority.MFA_CONFIGURATION_TOKEN); } else { tokenPair = tokenFactory.createTokenPair(securityUser); } @@ -69,6 +66,19 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc clearAuthenticationAttributes(request); } + public JwtPair createMfaTokenPair(SecurityUser securityUser, Authority scope) { + log.debug("[{}][{}] Creating {} token", securityUser.getTenantId(), securityUser.getId(), scope); + JwtPair tokenPair = new JwtPair(); + int preVerificationTokenLifetime = twoFaConfigManager.getPlatformTwoFaSettings(securityUser.getTenantId(), true) + .flatMap(settings -> Optional.ofNullable(settings.getTotalAllowedTimeForVerification()) + .filter(time -> time > 0)) + .orElse((int) TimeUnit.MINUTES.toSeconds(30)); + tokenPair.setToken(tokenFactory.createMfaToken(securityUser, scope, preVerificationTokenLifetime).getToken()); + tokenPair.setRefreshToken(null); + tokenPair.setScope(scope); + return tokenPair; + } + /** * Removes temporary authentication-related data which may have been stored * in the session during the authentication process.. 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 f9aa6db0f5..e7699e4950 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 @@ -115,13 +115,16 @@ public class JwtTokenFactory { throw new IllegalArgumentException("JWT Token doesn't have any scopes"); } + Authority authority = Authority.parse(scopes.get(0)); + SecurityUser securityUser = new SecurityUser(new UserId(UUID.fromString(claims.get(USER_ID, String.class)))); securityUser.setEmail(subject); - securityUser.setAuthority(Authority.parse(scopes.get(0))); + securityUser.setAuthority(authority); String tenantId = claims.get(TENANT_ID, String.class); + if (tenantId != null) { securityUser.setTenantId(TenantId.fromUUID(UUID.fromString(tenantId))); - } else if (securityUser.getAuthority() == Authority.SYS_ADMIN) { + } else if (authority == Authority.SYS_ADMIN) { securityUser.setTenantId(TenantId.SYS_TENANT_ID); } String customerId = claims.get(CUSTOMER_ID, String.class); @@ -132,18 +135,15 @@ public class JwtTokenFactory { securityUser.setSessionId(claims.get(SESSION_ID, String.class)); } - UserPrincipal principal; - if (securityUser.getAuthority() != Authority.PRE_VERIFICATION_TOKEN) { + boolean isPublic = false; + if (authority != Authority.PRE_VERIFICATION_TOKEN && authority != Authority.MFA_CONFIGURATION_TOKEN) { securityUser.setFirstName(claims.get(FIRST_NAME, String.class)); securityUser.setLastName(claims.get(LAST_NAME, String.class)); securityUser.setEnabled(claims.get(ENABLED, Boolean.class)); - boolean isPublic = claims.get(IS_PUBLIC, Boolean.class); - principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject); - } else { - principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, subject); + isPublic = claims.get(IS_PUBLIC, Boolean.class); } + UserPrincipal principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject); securityUser.setUserPrincipal(principal); - return securityUser; } @@ -179,8 +179,8 @@ public class JwtTokenFactory { return securityUser; } - public JwtToken createPreVerificationToken(SecurityUser user, Integer expirationTime) { - JwtBuilder jwtBuilder = setUpToken(user, Collections.singletonList(Authority.PRE_VERIFICATION_TOKEN.name()), expirationTime) + public JwtToken createMfaToken(SecurityUser user, Authority scope, Integer expirationTime) { + JwtBuilder jwtBuilder = setUpToken(user, Collections.singletonList(scope.name()), expirationTime) .claim(TENANT_ID, user.getTenantId().toString()); if (user.getCustomerId() != null) { jwtBuilder.claim(CUSTOMER_ID, user.getCustomerId().toString()); 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 8124671cd7..8c71bab9bf 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 @@ -28,7 +28,7 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.service.security.model.SecurityUser; -@Component(value = "customerUserPermissions") +@Component public class CustomerUserPermissions extends AbstractPermissions { public CustomerUserPermissions() { diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java b/application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java index a5feb1c502..6c54bbe68f 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java @@ -16,7 +16,6 @@ package org.thingsboard.server.service.security.permission; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; @@ -33,18 +32,18 @@ import java.util.Optional; @Slf4j public class DefaultAccessControlService implements AccessControlService { - private static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; private static final String YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION = "You don't have permission to perform this operation!"; private final Map authorityPermissions = new HashMap<>(); - public DefaultAccessControlService( - @Qualifier("sysAdminPermissions") Permissions sysAdminPermissions, - @Qualifier("tenantAdminPermissions") Permissions tenantAdminPermissions, - @Qualifier("customerUserPermissions") Permissions customerUserPermissions) { + public DefaultAccessControlService(SysAdminPermissions sysAdminPermissions, + TenantAdminPermissions tenantAdminPermissions, + CustomerUserPermissions customerUserPermissions, + MfaConfigurationPermissions mfaConfigurationPermissions) { authorityPermissions.put(Authority.SYS_ADMIN, sysAdminPermissions); authorityPermissions.put(Authority.TENANT_ADMIN, tenantAdminPermissions); authorityPermissions.put(Authority.CUSTOMER_USER, customerUserPermissions); + authorityPermissions.put(Authority.MFA_CONFIGURATION_TOKEN, mfaConfigurationPermissions); } @Override @@ -58,7 +57,7 @@ public class DefaultAccessControlService implements AccessControlService { @Override @SuppressWarnings("unchecked") public void checkPermission(SecurityUser user, Resource resource, - Operation operation, I entityId, T entity) throws ThingsboardException { + Operation operation, I entityId, T entity) throws ThingsboardException { PermissionChecker permissionChecker = getPermissionChecker(user.getAuthority(), resource); if (!permissionChecker.hasPermission(user, operation, entityId, entity)) { permissionDenied(); diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/MfaConfigurationPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/MfaConfigurationPermissions.java new file mode 100644 index 0000000000..b901030a67 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/MfaConfigurationPermissions.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.security.permission; + +import org.springframework.stereotype.Component; + +@Component +public class MfaConfigurationPermissions extends AbstractPermissions { + + public MfaConfigurationPermissions() { + super(); + // for compatibility with PE + } + +} 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 6bd7aacf54..2593040a12 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 @@ -23,7 +23,7 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.service.security.model.SecurityUser; -@Component(value = "sysAdminPermissions") +@Component public class SysAdminPermissions extends AbstractPermissions { public SysAdminPermissions() { 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 7a824ca735..f37b865ae2 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 @@ -25,7 +25,7 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.service.security.model.SecurityUser; -@Component(value = "tenantAdminPermissions") +@Component public class TenantAdminPermissions extends AbstractPermissions { public TenantAdminPermissions() { @@ -73,7 +73,7 @@ public class TenantAdminPermissions extends AbstractPermissions { }; private static final PermissionChecker tenantPermissionChecker = - new PermissionChecker.GenericPermissionChecker(Operation.READ, Operation.READ_ATTRIBUTES, Operation.READ_TELEMETRY) { + new PermissionChecker.GenericPermissionChecker(Operation.READ, Operation.READ_ATTRIBUTES, Operation.READ_TELEMETRY, Operation.DELETE) { @Override @SuppressWarnings("unchecked") diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java index b7b027d785..5d634c4178 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java @@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.ExportableEntity; import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; @@ -154,12 +155,20 @@ public class DefaultEntityExportService calculatedFields = calculatedFieldService.findCalculatedFieldsByEntityId(ctx.getTenantId(), entityId); calculatedFields.forEach(calculatedField -> { calculatedField.setEntityId(getExternalIdOrElseInternal(ctx, entityId)); - if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration configuration) { - configuration.getArguments().values().forEach(argument -> { - if (argument.getRefEntityId() != null) { - argument.setRefEntityId(getExternalIdOrElseInternal(ctx, argument.getRefEntityId())); - } - }); + if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration argBasedConfig) { + if (argBasedConfig instanceof GeofencingCalculatedFieldConfiguration geofencingCfg) { + geofencingCfg.getZoneGroups().values().forEach(zoneGroupConfiguration -> { + if (zoneGroupConfiguration.getRefEntityId() != null) { + zoneGroupConfiguration.setRefEntityId(getExternalIdOrElseInternal(ctx, zoneGroupConfiguration.getRefEntityId())); + } + }); + } else { + argBasedConfig.getArguments().values().forEach(argument -> { + if (argument.getRefEntityId() != null) { + argument.setRefEntityId(getExternalIdOrElseInternal(ctx, argument.getRefEntityId())); + } + }); + } } }); return calculatedFields; diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java index 9850e2d1a1..bdea24bb2a 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.sync.ie.importing.csv; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FutureCallback; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import jakarta.annotation.Nullable; @@ -183,10 +184,16 @@ public abstract class AbstractBulkImportService dataEntry.getKey().getType() == kvType && StringUtils.isNotEmpty(dataEntry.getKey().getKey())) - .forEach(dataEntry -> kvs.add(dataEntry.getKey().getKey(), dataEntry.getValue().toJsonPrimitive())); + .forEach(dataEntry -> { + ParsedValue value = dataEntry.getValue(); + JsonElement kvValue = (value.getDataType() == DataType.JSON) + ? (JsonElement) value.getValue() + : value.toJsonPrimitive(); + kvs.add(dataEntry.getKey().getKey(), kvValue); + }); return Map.entry(kvType, kvs); }) - .filter(kvsEntry -> kvsEntry.getValue().entrySet().size() > 0) + .filter(kvsEntry -> !kvsEntry.getValue().entrySet().isEmpty()) .forEach(kvsEntry -> { BulkImportColumnType kvType = kvsEntry.getKey(); if (kvType == BulkImportColumnType.SHARED_ATTRIBUTE || kvType == BulkImportColumnType.SERVER_ATTRIBUTE) { diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java index 2b606a2fe8..c95fffc205 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java @@ -36,6 +36,7 @@ import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; @@ -322,12 +323,20 @@ public abstract class BaseEntityImportService { calculatedField.setTenantId(ctx.getTenantId()); calculatedField.setEntityId(savedEntity.getId()); - if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration configuration) { - configuration.getArguments().values().forEach(argument -> { - if (argument.getRefEntityId() != null) { - argument.setRefEntityId(idProvider.getInternalId(argument.getRefEntityId(), ctx.isFinalImportAttempt())); - } - }); + if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration argBasedConfig) { + if (argBasedConfig instanceof GeofencingCalculatedFieldConfiguration geofencingCfg) { + geofencingCfg.getZoneGroups().values().forEach(zoneGroupConfiguration -> { + if (zoneGroupConfiguration.getRefEntityId() != null) { + zoneGroupConfiguration.setRefEntityId(idProvider.getInternalId(zoneGroupConfiguration.getRefEntityId(), ctx.isFinalImportAttempt())); + } + }); + } else { + argBasedConfig.getArguments().values().forEach(argument -> { + if (argument.getRefEntityId() != null) { + argument.setRefEntityId(idProvider.getInternalId(argument.getRefEntityId(), ctx.isFinalImportAttempt())); + } + }); + } } }).toList(); diff --git a/application/src/main/java/org/thingsboard/server/service/system/SystemInfoService.java b/application/src/main/java/org/thingsboard/server/service/system/SystemInfoService.java index faae80a942..e2edd8a9cc 100644 --- a/application/src/main/java/org/thingsboard/server/service/system/SystemInfoService.java +++ b/application/src/main/java/org/thingsboard/server/service/system/SystemInfoService.java @@ -19,7 +19,9 @@ import org.thingsboard.server.common.data.FeaturesInfo; import org.thingsboard.server.common.data.SystemInfo; public interface SystemInfoService { + SystemInfo getSystemInfo(); FeaturesInfo getFeaturesInfo(); + } diff --git a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java new file mode 100644 index 0000000000..204d2b5d6c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java @@ -0,0 +1,272 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.system; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.hash.Hashing; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; +import org.thingsboard.server.service.install.InstallScripts; +import org.thingsboard.server.service.install.update.DefaultDataUpdateService; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +/** + * Runs at application startup and applies no-downtime data updates + * when the package PATCH version increases (e.g., 4.2.1.0 -> 4.2.1.1). + */ +@Slf4j +@Component +@TbCoreComponent +@RequiredArgsConstructor +public class SystemPatchApplier { + + private static final long ADVISORY_LOCK_ID = 7536891047216478431L; + + private final JdbcTemplate jdbcTemplate; + private final InstallScripts installScripts; + private final DatabaseSchemaSettingsService schemaSettingsService; + private final WidgetTypeService widgetTypeService; + + @PostConstruct + private void init() { + ExecutorService executor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("system-patch-applier")); + executor.submit(() -> { + try { + applyPatchIfNeeded(); + } catch (Exception e) { + log.error("Failed to apply system data patch updates", e); + } finally { + executor.shutdown(); + } + }); + } + + private void applyPatchIfNeeded() { + boolean skipVersionCheck = DefaultDataUpdateService.getEnv("SKIP_PATCH_VERSION_CHECK", false); + if (!skipVersionCheck && !isVersionChanged()) { + return; + } + + if (!acquireAdvisoryLock()) { + log.trace("Could not acquire advisory lock. Another node is processing patch updates."); + return; + } + + try { + int updated = updateWidgetTypes(); + log.info("Updated {} widget types", updated); + + schemaSettingsService.updateSchemaVersion(); + log.info("System data patch update completed successfully"); + + } finally { + releaseAdvisoryLock(); + } + } + + private boolean isVersionChanged() { + String packageVersion = schemaSettingsService.getPackageSchemaVersion(); + String dbVersion = schemaSettingsService.getDbSchemaVersion(); + + log.trace("Package version: {}, DB schema version: {}", packageVersion, dbVersion); + + VersionInfo packageVersionInfo = parseVersion(packageVersion); + VersionInfo dbVersionInfo = parseVersion(dbVersion); + + if (packageVersionInfo == null || dbVersionInfo == null) { + log.warn("Unable to parse versions. Package: {}, DB: {}", packageVersion, dbVersion); + return false; + } + + if (!isPatchVersionChanged(packageVersionInfo, dbVersionInfo)) { + return false; + } + + log.info("Patch version increased from {} to {}. Starting system data update.", dbVersion, packageVersion); + return true; + } + + private boolean isPatchVersionChanged(VersionInfo packageVersion, VersionInfo dbVersion) { + return packageVersion.major == dbVersion.major && packageVersion.minor == dbVersion.minor + && packageVersion.maintenance == dbVersion.maintenance && packageVersion.patch > dbVersion.patch; + } + + private int updateWidgetTypes() { + AtomicInteger updated = new AtomicInteger(); + Path widgetTypesDir = installScripts.getWidgetTypesDir(); + + if (!Files.exists(widgetTypesDir)) { + log.trace("Widget types directory does not exist: {}", widgetTypesDir); + return 0; + } + + try (Stream dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) { + dirStream.forEach( + path -> { + try { + if (updateWidgetTypeFromFile(path)) { + updated.incrementAndGet(); + } + } catch (Exception e) { + log.error("Unable to update widget type from json: [{}]", path.toString()); + throw new RuntimeException("Unable to update widget type from json", e); + } + } + ); + } + + return updated.get(); + } + + private boolean updateWidgetTypeFromFile(Path filePath) { + JsonNode json = JacksonUtil.toJsonNode(filePath.toFile()); + WidgetTypeDetails fileWidgetType = JacksonUtil.treeToValue(json, WidgetTypeDetails.class); + String fqn = fileWidgetType.getFqn(); + + WidgetTypeDetails existingWidgetType = widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, fqn); + if (existingWidgetType == null) { + // We expect only update here, so it's probably never happening, but for test purpose leave it like this: + throw new RuntimeException("Widget type not found: " + fqn); + } + if (isWidgetTypeChanged(existingWidgetType, fileWidgetType)) { + existingWidgetType.setDescription(fileWidgetType.getDescription()); + existingWidgetType.setName(fileWidgetType.getName()); + existingWidgetType.setDescriptor(fileWidgetType.getDescriptor()); + widgetTypeService.saveWidgetType(existingWidgetType); + log.trace("Updated widget type: {}", fqn); + return true; + } + + log.trace("Widget type unchanged: {}", fqn); + return false; + } + + private boolean isWidgetTypeChanged(WidgetTypeDetails existing, WidgetTypeDetails file) { + if (!isDescriptorEqual(existing.getDescriptor(), file.getDescriptor())) { + return true; + } + + if (!Objects.equals(existing.getName(), file.getName())) { + return true; + } + + return !Objects.equals(existing.getDescription(), file.getDescription()); + } + + private boolean isDescriptorEqual(JsonNode desc1, JsonNode desc2) { + if (desc1 == null && desc2 == null) { + return true; + } + if (desc1 == null || desc2 == null) { + return false; + } + + try { + String hash1 = computeChecksum(desc1); + String hash2 = computeChecksum(desc2); + return Objects.equals(hash1, hash2); + } catch (Exception e) { + log.warn("Failed to compare descriptors using checksum, falling back to equals", e); + return desc1.equals(desc2); + } + } + + private String computeChecksum(JsonNode node) { + String canonicalString = JacksonUtil.toCanonicalString(node); + if (canonicalString == null) { + return null; + } + return Hashing.sha256().hashBytes(canonicalString.getBytes()).toString(); + } + + private boolean acquireAdvisoryLock() { + try { + Boolean acquired = jdbcTemplate.queryForObject( + "SELECT pg_try_advisory_lock(?)", + Boolean.class, + ADVISORY_LOCK_ID + ); + if (Boolean.TRUE.equals(acquired)) { + log.trace("Acquired advisory lock"); + return true; + } + return false; + } catch (Exception e) { + log.error("Failed to acquire advisory lock", e); + return false; + } + } + + private void releaseAdvisoryLock() { + try { + jdbcTemplate.queryForObject( + "SELECT pg_advisory_unlock(?)", + Boolean.class, + ADVISORY_LOCK_ID + ); + log.debug("Released advisory lock"); + } catch (Exception e) { + log.error("Failed to release advisory lock", e); + } + } + + private VersionInfo parseVersion(String version) { + try { + String[] parts = version.split("\\."); + int major = Integer.parseInt(parts[0]); + int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0; + int maintenance = parts.length > 2 ? Integer.parseInt(parts[2]) : 0; + int patch = parts.length > 3 ? Integer.parseInt(parts[3]) : 0; + return new VersionInfo(major, minor, maintenance, patch); + } catch (Exception e) { + log.error("Failed to parse version: {}", version, e); + return null; + } + } + + private Stream listDir(Path dir) { + try { + return Files.list(dir); + } catch (NoSuchFileException e) { + return Stream.empty(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public record VersionInfo(int major, int minor, int maintenance, int patch) {} + +} diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java index 8c4a375fae..b68f604460 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java @@ -102,7 +102,12 @@ public class DefaultAlarmSubscriptionService extends AbstractSubscriptionService @Override public AlarmApiCallResult clearAlarm(TenantId tenantId, AlarmId alarmId, long clearTs, JsonNode details) { - return withWsCallback(alarmService.clearAlarm(tenantId, alarmId, clearTs, details)); + return clearAlarm(tenantId, alarmId, clearTs, details, true); + } + + @Override + public AlarmApiCallResult clearAlarm(TenantId tenantId, AlarmId alarmId, long clearTs, JsonNode details, boolean pushEvent) { + return withWsCallback(alarmService.clearAlarm(tenantId, alarmId, clearTs, details, pushEvent)); } @Override diff --git a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java index 934eadd98f..0c0d401688 100644 --- a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java +++ b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java @@ -21,6 +21,9 @@ import com.google.common.util.concurrent.MoreExecutors; import org.apache.commons.lang3.math.NumberUtils; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; @@ -28,10 +31,13 @@ import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; -import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState; import java.util.Optional; @@ -64,11 +70,19 @@ public class CalculatedFieldArgumentUtils { return new StringDataEntry(key, defaultValue); } - public static CalculatedFieldState createStateByType(CalculatedFieldCtx ctx) { + public static AttributeKvEntry createDefaultAttributeEntry(Argument argument, long ts) { + KvEntry kvEntry = createDefaultKvEntry(argument); + return new BaseAttributeKvEntry(kvEntry, ts, 0L); + } + + public static CalculatedFieldState createStateByType(CalculatedFieldCtx ctx, EntityId entityId) { return switch (ctx.getCfType()) { - case SIMPLE -> new SimpleCalculatedFieldState(ctx.getArgNames()); - case SCRIPT -> new ScriptCalculatedFieldState(ctx.getArgNames()); - case GEOFENCING -> new GeofencingCalculatedFieldState(ctx.getArgNames()); + case SIMPLE -> new SimpleCalculatedFieldState(entityId); + case SCRIPT -> new ScriptCalculatedFieldState(entityId); + case GEOFENCING -> new GeofencingCalculatedFieldState(entityId); + case ALARM -> new AlarmCalculatedFieldState(entityId); + case PROPAGATION -> new PropagationCalculatedFieldState(entityId); + case RELATED_ENTITIES_AGGREGATION -> new RelatedEntitiesAggregationCalculatedFieldState(entityId); }; } diff --git a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java index 4e93c8233e..121febea7d 100644 --- a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java +++ b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java @@ -17,6 +17,7 @@ package org.thingsboard.server.utils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus; import org.thingsboard.server.common.data.id.CalculatedFieldId; @@ -26,6 +27,8 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.BasicKvEntry; import org.thingsboard.server.common.util.KvProtoUtil; import org.thingsboard.server.common.util.ProtoUtils; +import org.thingsboard.server.gen.transport.TransportProtos.AlarmRuleStateProto; +import org.thingsboard.server.gen.transport.TransportProtos.AlarmStateProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldEntityCtxIdProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldIdProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; @@ -38,14 +41,20 @@ import org.thingsboard.server.gen.transport.TransportProtos.TsValueProto; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; -import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; -import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; -import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState; import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.TsRollingArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmRuleState; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState; +import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.TreeMap; @@ -87,17 +96,51 @@ public class CalculatedFieldUtils { .setType(state.getType().name()); state.getArguments().forEach((argName, argEntry) -> { - if (argEntry instanceof SingleValueArgumentEntry singleValueArgumentEntry) { - builder.addSingleValueArguments(toSingleValueArgumentProto(argName, singleValueArgumentEntry)); - } else if (argEntry instanceof TsRollingArgumentEntry rollingArgumentEntry) { - builder.addRollingValueArguments(toRollingArgumentProto(argName, rollingArgumentEntry)); - } else if (argEntry instanceof GeofencingArgumentEntry geofencingArgumentEntry) { - builder.addGeofencingArguments(toGeofencingArgumentProto(argName, geofencingArgumentEntry)); + switch (argEntry.getType()) { + case SINGLE_VALUE -> builder.addSingleValueArguments(toSingleValueArgumentProto(argName, (SingleValueArgumentEntry) argEntry)); + case TS_ROLLING -> builder.addRollingValueArguments(toRollingArgumentProto(argName, (TsRollingArgumentEntry) argEntry)); + case GEOFENCING -> builder.addGeofencingArguments(toGeofencingArgumentProto(argName, (GeofencingArgumentEntry) argEntry)); + case RELATED_ENTITIES -> { + RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry = (RelatedEntitiesArgumentEntry) argEntry; + relatedEntitiesArgumentEntry.getEntityInputs() + .forEach((entityId, entry) -> builder.addSingleValueArguments(toSingleValueArgumentProto(argName, (SingleValueArgumentEntry) entry))); + } } }); + if (state instanceof AlarmCalculatedFieldState alarmState) { + AlarmStateProto.Builder alarmStateProto = AlarmStateProto.newBuilder(); + alarmState.getCreateRuleStates().forEach((severity, ruleState) -> { + alarmStateProto.addCreateRuleStates(toAlarmRuleStateProto(ruleState)); + }); + if (alarmState.getClearRuleState() != null) { + alarmStateProto.setClearRuleState(toAlarmRuleStateProto(alarmState.getClearRuleState())); + } + } + if (state instanceof RelatedEntitiesAggregationCalculatedFieldState aggState) { + builder.setLastArgsUpdateTs(aggState.getLastArgsRefreshTs()); + builder.setLastMetricsEvalTs(aggState.getLastMetricsEvalTs()); + } return builder.build(); } + private static AlarmRuleStateProto toAlarmRuleStateProto(AlarmRuleState ruleState) { + return AlarmRuleStateProto.newBuilder() + .setSeverity(Optional.ofNullable(ruleState.getSeverity()).map(Enum::name).orElse("")) + .setEventCount(ruleState.getEventCount()) + .setFirstEventTs(ruleState.getFirstEventTs()) + .setLastEventTs(ruleState.getLastEventTs()) + .build(); + } + + private static AlarmRuleState fromAlarmRuleStateProto(AlarmRuleStateProto proto, AlarmCalculatedFieldState state) { + AlarmSeverity severity = StringUtils.isNotEmpty(proto.getSeverity()) ? AlarmSeverity.valueOf(proto.getSeverity()) : null; + AlarmRuleState ruleState = new AlarmRuleState(severity, null, state); + ruleState.setEventCount(proto.getEventCount()); + ruleState.setFirstEventTs(proto.getFirstEventTs()); + ruleState.setLastEventTs(proto.getLastEventTs()); + return ruleState; + } + public static SingleValueArgumentProto toSingleValueArgumentProto(String argName, SingleValueArgumentEntry entry) { SingleValueArgumentProto.Builder builder = SingleValueArgumentProto.newBuilder() .setArgName(argName); @@ -108,6 +151,10 @@ public class CalculatedFieldUtils { Optional.ofNullable(entry.getVersion()).ifPresent(builder::setVersion); + if (entry.getEntityId() != null) { + builder.setEntityId(ProtoUtils.toProto(entry.getEntityId())); + } + return builder.build(); } @@ -143,7 +190,7 @@ public class CalculatedFieldUtils { return builder.build(); } - public static CalculatedFieldState fromProto(CalculatedFieldStateProto proto) { + public static CalculatedFieldState fromProto(CalculatedFieldEntityCtxId id, CalculatedFieldStateProto proto) { if (StringUtils.isEmpty(proto.getType())) { return null; } @@ -151,22 +198,52 @@ public class CalculatedFieldUtils { CalculatedFieldType type = CalculatedFieldType.valueOf(proto.getType()); CalculatedFieldState state = switch (type) { - case SIMPLE -> new SimpleCalculatedFieldState(); - case SCRIPT -> new ScriptCalculatedFieldState(); - case GEOFENCING -> new GeofencingCalculatedFieldState(); + case SIMPLE -> new SimpleCalculatedFieldState(id.entityId()); + case SCRIPT -> new ScriptCalculatedFieldState(id.entityId()); + case GEOFENCING -> new GeofencingCalculatedFieldState(id.entityId()); + case ALARM -> new AlarmCalculatedFieldState(id.entityId()); + case PROPAGATION -> new PropagationCalculatedFieldState(id.entityId()); + case RELATED_ENTITIES_AGGREGATION -> new RelatedEntitiesAggregationCalculatedFieldState(id.entityId()); }; - proto.getSingleValueArgumentsList().forEach(argProto -> - state.getArguments().put(argProto.getArgName(), fromSingleValueArgumentProto(argProto))); + if (state instanceof RelatedEntitiesAggregationCalculatedFieldState relatedEntitiesAggState) { + Map> arguments = new HashMap<>(); + proto.getSingleValueArgumentsList().forEach(argProto -> { + SingleValueArgumentEntry entry = fromSingleValueArgumentProto(argProto); + arguments.computeIfAbsent(argProto.getArgName(), name -> new HashMap<>()).put(entry.getEntityId(), entry); + }); + arguments.forEach((argName, entityInputs) -> { + relatedEntitiesAggState.getArguments().put(argName, new RelatedEntitiesArgumentEntry(entityInputs, false)); + }); + relatedEntitiesAggState.setLastArgsRefreshTs(proto.getLastArgsUpdateTs()); + relatedEntitiesAggState.setLastMetricsEvalTs(proto.getLastMetricsEvalTs()); - if (CalculatedFieldType.SCRIPT.equals(type)) { - proto.getRollingValueArgumentsList().forEach(argProto -> - state.getArguments().put(argProto.getKey(), fromRollingArgumentProto(argProto))); + return relatedEntitiesAggState; } - if (CalculatedFieldType.GEOFENCING.equals(type)) { - proto.getGeofencingArgumentsList().forEach(argProto -> - state.getArguments().put(argProto.getArgName(), fromGeofencingArgumentProto(argProto))); + proto.getSingleValueArgumentsList().forEach(argProto -> + state.getArguments().put(argProto.getArgName(), fromSingleValueArgumentProto(argProto))); + + switch (type) { + case SCRIPT -> { + proto.getRollingValueArgumentsList().forEach(argProto -> + state.getArguments().put(argProto.getKey(), fromRollingArgumentProto(argProto))); + } + case GEOFENCING -> { + proto.getGeofencingArgumentsList().forEach(argProto -> + state.getArguments().put(argProto.getArgName(), fromGeofencingArgumentProto(argProto))); + } + case ALARM -> { + AlarmCalculatedFieldState alarmState = (AlarmCalculatedFieldState) state; + AlarmStateProto alarmStateProto = proto.getAlarmState(); + for (AlarmRuleStateProto ruleStateProto : alarmStateProto.getCreateRuleStatesList()) { + AlarmRuleState ruleState = fromAlarmRuleStateProto(ruleStateProto, alarmState); + alarmState.getCreateRuleStates().put(ruleState.getSeverity(), ruleState); + } + if (alarmStateProto.hasClearRuleState()) { + alarmState.setClearRuleState(fromAlarmRuleStateProto(alarmStateProto.getClearRuleState(), alarmState)); + } + } } return state; @@ -177,11 +254,14 @@ public class CalculatedFieldUtils { return new SingleValueArgumentEntry(); } TsValueProto tsValueProto = proto.getValue(); - return new SingleValueArgumentEntry( - tsValueProto.getTs(), - (BasicKvEntry) KvProtoUtil.fromTsValueProto(proto.getArgName(), tsValueProto), - proto.getVersion() - ); + BasicKvEntry kvEntry = (BasicKvEntry) KvProtoUtil.fromTsValueProto(proto.getArgName(), tsValueProto); + long ts = tsValueProto.getTs(); + long version = proto.getVersion(); + if (proto.hasEntityId()) { + EntityId entityId = ProtoUtils.fromProto(proto.getEntityId()); + return new SingleValueArgumentEntry(entityId, ts, kvEntry, version); + } + return new SingleValueArgumentEntry(ts, kvEntry, version); } public static TsRollingArgumentEntry fromRollingArgumentProto(TsRollingArgumentProto proto) { diff --git a/application/src/main/java/org/thingsboard/server/utils/CsvUtils.java b/application/src/main/java/org/thingsboard/server/utils/CsvUtils.java index 3f75a4918f..0fde72a3b0 100644 --- a/application/src/main/java/org/thingsboard/server/utils/CsvUtils.java +++ b/application/src/main/java/org/thingsboard/server/utils/CsvUtils.java @@ -17,10 +17,15 @@ package org.thingsboard.server.utils; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import lombok.SneakyThrows; import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVPrinter; import org.apache.commons.csv.CSVRecord; import org.apache.commons.io.input.CharSequenceReader; +import java.io.ByteArrayOutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -43,4 +48,18 @@ public class CsvUtils { .collect(Collectors.toList()); } + @SneakyThrows + public static byte[] generateCsv(List> rows) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (OutputStreamWriter writer = new OutputStreamWriter(out, StandardCharsets.UTF_8); + CSVPrinter csvPrinter = new CSVPrinter(writer, CSVFormat.DEFAULT)) { + + for (List row : rows) { + csvPrinter.printRecord(row); + } + csvPrinter.flush(); + } + return out.toByteArray(); + } + } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 68cd479411..889df54848 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -306,8 +306,11 @@ cassandra: default_fetch_size: "${CASSANDRA_DEFAULT_FETCH_SIZE:2000}" # Specify partitioning size for timestamp key-value storage. Example: MINUTES, HOURS, DAYS, MONTHS, INDEFINITE ts_key_value_partitioning: "${TS_KV_PARTITIONING:MONTHS}" - # Enable/Disable timestamp key-value partioning on read queries + # Enable/Disable timestamp key-value partitioning on read queries use_ts_key_value_partitioning_on_read: "${USE_TS_KV_PARTITIONING_ON_READ:true}" + # Safety trigger to fall back to use_ts_key_value_partitioning_on_read as true if estimated partitions count is greater than safety trigger value. + # It helps to prevent building huge partition list (OOM) for corner cases (like from 0 to infinity) and prefer fewer reads strategy from NoSQL database + use_ts_key_value_partitioning_on_read_max_estimated_partition_count: "${USE_TS_KV_PARTITIONING_ON_READ_MAX_ESTIMATED_PARTITION_COUNT:40}" # The number of partitions that are cached in memory of each service. It is useful to decrease the load of re-inserting the same partitions again ts_key_value_partitions_max_cache_size: "${TS_KV_PARTITIONS_MAX_CACHE_SIZE:100000}" # Timeseries Time To Live (in seconds) for Cassandra Record. 0 - record has never expired @@ -526,6 +529,9 @@ actors: configuration: "${ACTORS_CALCULATED_FIELD_DEBUG_MODE_RATE_LIMITS_PER_TENANT_CONFIGURATION:50000:3600}" # Time in seconds to receive calculation result. calculation_timeout: "${ACTORS_CALCULATION_TIMEOUT_SEC:5}" + alarms: + # Interval in seconds to re-evaluate Alarm rules that have a time schedule. 2 minutes by default. + reevaluation_interval: "${ACTORS_ALARMS_REEVALUATION_INTERVAL_SEC:120}" debug: settings: @@ -679,6 +685,9 @@ cache: maxSize: "${CACHE_SPECS_IMAGE_ETAGS_MAX_SIZE:10000}" # 0 means the cache is disabled systemImagesBrowserTtlInMinutes: "${CACHE_SPECS_IMAGE_SYSTEM_BROWSER_TTL:0}" # Browser cache TTL for system images in minutes. 0 means the cache is disabled tenantImagesBrowserTtlInMinutes: "${CACHE_SPECS_IMAGE_TENANT_BROWSER_TTL:0}" # Browser cache TTL for tenant images in minutes. 0 means the cache is disabled + tbResourceData: + timeToLiveInMinutes: "${CACHE_SPECS_RESOURCE_DATA_TTL:10080}" # TB resource data cache TTL + maxSize: "${CACHE_SPECS_RESOURCE_DATA_MAX_SIZE:100000}" # 0 means the cache is disabled # Spring data parameters spring.data.redis.repositories.enabled: false # Disable this because it is not required. @@ -1193,7 +1202,7 @@ transport: # Enable/disable Bootstrap Server enabled: "${LWM2M_ENABLED_BS:true}" # Default value in LwM2M client after start in mode Bootstrap for the object : name "LWM2M Security" field: "Short Server ID" (deviceProfile: Bootstrap.BOOTSTRAP SERVER.Short ID) - id: "${LWM2M_SERVER_ID_BS:111}" + id: "${LWM2M_SERVER_ID_BS:0}" # LwM2M bootstrap server bind address. Bind to all interfaces by default bind_address: "${LWM2M_BS_BIND_ADDRESS:0.0.0.0}" # LwM2M bootstrap server bind port @@ -1542,7 +1551,7 @@ swagger: version: "${SWAGGER_VERSION:}" # The group name (definition) on the API doc UI page. group_name: "${SWAGGER_GROUP_NAME:thingsboard}" - # Control the initial display state of API operations and tags (none, list or full) + # Control the initial display state of API operations and tags (none or list) doc_expansion: "${SWAGGER_DOC_EXPANSION:list}" # Queue configuration parameters diff --git a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java new file mode 100644 index 0000000000..b4174e357b --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java @@ -0,0 +1,907 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.action.TbAlarmResult; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.alarm.AlarmInfo; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.alarm.AlarmStatus; +import org.thingsboard.server.common.data.alarm.rule.AlarmRule; +import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; +import org.thingsboard.server.common.data.alarm.rule.condition.DurationAlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.RepeatingAlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate.NumericOperation; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.StringFilterPredicate; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.StringFilterPredicate.StringOperation; +import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AlarmSchedule; +import org.thingsboard.server.common.data.alarm.rule.condition.schedule.SpecificTimeSchedule; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.CurrentOwnerDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.debug.DebugSettings; +import org.thingsboard.server.common.data.event.CalculatedFieldDebugEvent; +import org.thingsboard.server.common.data.event.EventType; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EventId; +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.event.EventDao; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + +@Slf4j +@DaoSqlTest +@TestPropertySource(properties = { + "actors.alarms.reevaluation_interval=1" +}) +public class AlarmRulesTest extends AbstractControllerTest { + + @MockitoSpyBean + private ActorSystemContext actorSystemContext; + + @Autowired + private EventDao eventDao; + + private Device device; + private DeviceId deviceId; + private EntityId originatorId; + private EventId latestEventId; + + @Before + public void beforeEach() throws Exception { + loginTenantAdmin(); + device = createDevice("Device A", "aaa"); + deviceId = device.getId(); + originatorId = deviceId; + } + + @Test + public void testCreateAlarm_severityUpdate_clear() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + Map createRules = Map.of( + AlarmSeverity.MAJOR, new Condition("return temperature >= 50;", null, null), + AlarmSeverity.CRITICAL, new Condition("return temperature >= 100;", null, null) + ); + + Condition clearRule = new Condition("return temperature <= 25;", null, null); + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, clearRule); + + postTelemetry(deviceId, "{\"temperature\":50}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + + postTelemetry(deviceId, "{\"temperature\":100}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isSeverityUpdated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + + postTelemetry(deviceId, "{\"temperature\":101}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isUpdated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + + postTelemetry(deviceId, "{\"temperature\":20}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCleared()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK); + }); + } + + @Test + public void testCreateAlarm_simpleConditionExpression() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + SimpleAlarmConditionExpression simpleExpression = new SimpleAlarmConditionExpression(); + AlarmConditionFilter filter = new AlarmConditionFilter(); + filter.setArgument("temperature"); + filter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate predicate = new NumericFilterPredicate(); + predicate.setOperation(NumericOperation.GREATER_OR_EQUAL); + AlarmConditionValue thresholdValue = new AlarmConditionValue<>(); + thresholdValue.setStaticValue(100.0); + predicate.setValue(thresholdValue); + filter.setPredicates(List.of(predicate)); + simpleExpression.setFilters(List.of(filter)); + simpleExpression.setOperation(ComplexOperation.AND); + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition(simpleExpression, null, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + + postTelemetry(deviceId, "{\"temperature\":100}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + @Test + public void testCreateAlarm_repeatingCondition() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + int eventsCountMajor = 5; + int eventsCountCritical = 10; + Map createRules = Map.of( + AlarmSeverity.MAJOR, new Condition("return temperature >= 50;", eventsCountMajor, null), + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", eventsCountCritical, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + for (int i = 0; i < 4; i++) { + postTelemetry(deviceId, "{\"temperature\":50}"); + Thread.sleep(10); + } + assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + postTelemetry(deviceId, "{\"temperature\":50}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + assertThat(alarmResult.getConditionRepeats()).isEqualTo(5); + }); + + for (int i = 0; i < 4; i++) { + postTelemetry(deviceId, "{\"temperature\":50}"); + Thread.sleep(10); + } + checkAlarmResult(calculatedField, alarmResult -> alarmResult.getConditionRepeats() == 9, alarmResult -> { + assertThat(alarmResult.isUpdated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); + }); + postTelemetry(deviceId, "{\"temperature\":50}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isSeverityUpdated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + assertThat(alarmResult.getConditionRepeats()).isEqualTo(10); + }); + } + + @Test + public void testCreateAlarm_dynamicRepeatingCondition() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + + Argument eventsCountArgument = new Argument(); + eventsCountArgument.setRefEntityKey(new ReferencedEntityKey("eventsCount", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + eventsCountArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument, + "eventsCount", eventsCountArgument + ); + + int eventsCount = 5; + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, + new AlarmConditionValue<>(null, "eventsCount"), null, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"eventsCount\":" + eventsCount + "}"); + for (int i = 0; i < eventsCount; i++) { + postTelemetry(deviceId, "{\"temperature\":50}"); + Thread.sleep(10); + } + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + assertThat(alarmResult.getConditionRepeats()).isEqualTo(eventsCount); + }); + } + + @Test + public void testCreateAlarm_durationCondition() throws Exception { + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("powerConsumption", ArgumentType.TS_LATEST, null)); + argument.setDefaultValue("0"); + Map arguments = Map.of( + "powerConsumption", argument + ); + + long createDurationMs = 5000L; + long clearDurationMs = 3000L; + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return powerConsumption >= 3000;", null, createDurationMs) + ); + Condition clearRule = new Condition("return powerConsumption < 3000;", null, clearDurationMs); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 5 seconds", + arguments, createRules, clearRule); + postTelemetry(deviceId, "{\"powerConsumption\":3500}"); + Thread.sleep(createDurationMs - 2000); + assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + assertThat(alarmResult.getConditionDuration()).isBetween(createDurationMs, createDurationMs + 2000); + }); + + postTelemetry(deviceId, "{\"powerConsumption\":2000}"); + Thread.sleep(clearDurationMs - 2000); + assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCleared()).isTrue(); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK); + assertThat(alarmResult.getConditionDuration()).isBetween(clearDurationMs, clearDurationMs + 2000); + }); + } + + @Test + public void testCreateAlarm_dynamicDurationCondition() throws Exception { + Argument powerConsumptionArgument = new Argument(); + powerConsumptionArgument.setRefEntityKey(new ReferencedEntityKey("powerConsumption", ArgumentType.TS_LATEST, null)); + powerConsumptionArgument.setDefaultValue("0"); + + Argument durationArgument = new Argument(); + durationArgument.setRefEntityKey(new ReferencedEntityKey("duration", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + durationArgument.setDefaultValue("-1"); + Map arguments = Map.of( + "powerConsumption", powerConsumptionArgument, + "duration", durationArgument + ); + + long createDurationMs = 2000L; + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return powerConsumption >= 3000;", null, null, + new AlarmConditionValue(null, "duration"), null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 2 seconds", + arguments, createRules, null); + postTelemetry(deviceId, "{\"powerConsumption\":3500}"); + postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"duration\":" + createDurationMs + "}"); + + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + assertThat(alarmResult.getConditionDuration()).isBetween(createDurationMs, createDurationMs + 2000); + }); + } + + @Test + public void testCreateAlarm_currentOwnerArgument() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + + Argument temperatureThresholdArgument = new Argument(); + temperatureThresholdArgument.setRefEntityKey(new ReferencedEntityKey("temperatureThreshold", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + temperatureThresholdArgument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); + temperatureThresholdArgument.setDefaultValue("1000"); + + Map arguments = Map.of( + "temperature", temperatureArgument, + "temperatureThreshold", temperatureThresholdArgument + ); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= temperatureThreshold;", null, null) + ); + + device.setCustomerId(customerId); + device = doPost("/api/device", device, Device.class); + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"temperatureThreshold\":50}"); + + postTelemetry(deviceId, "{\"temperature\":51}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + @Test + public void testCreateAndClearAlarm_customerAlarmRule_simpleExpression() throws Exception { + Argument locationArgument = new Argument(); + locationArgument.setRefEntityKey(new ReferencedEntityKey("location", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + locationArgument.setDefaultValue("unknown"); + originatorId = customerId; + + Argument locationFilterArgument = new Argument(); + locationFilterArgument.setRefEntityKey(new ReferencedEntityKey("locationFilter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + locationFilterArgument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); + locationFilterArgument.setDefaultValue("None"); + + Map arguments = Map.of( + "location", locationArgument, + "locationFilter", locationFilterArgument + ); + + Map createRules = Map.of( + AlarmSeverity.INDETERMINATE, new Condition(createSimpleExpression( + "location", StringOperation.CONTAINS, new AlarmConditionValue<>(null, "locationFilter") + ), null, null) + ); + Condition clearRule = new Condition(createSimpleExpression( + "location", StringOperation.NOT_CONTAINS, new AlarmConditionValue<>(null, "locationFilter") + ), null, null); + + CalculatedField calculatedField = createAlarmCf(customerId, "New resident", + arguments, createRules, clearRule); + + loginSysAdmin(); + postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"locationFilter\":\"Kyiv\"}"); + loginTenantAdmin(); + postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"location\":\"Ukraine, Kyiv\"}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.INDETERMINATE); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + + postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"location\":\"Ukraine, Lviv\"}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCleared()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.INDETERMINATE); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK); + }); + } + + @Test + public void testCreateAlarm_dynamicSchedule() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Argument scheduleArgument = new Argument(); + scheduleArgument.setRefEntityKey(new ReferencedEntityKey("schedule", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + scheduleArgument.setDefaultValue("None"); + Map arguments = Map.of( + "temperature", temperatureArgument, + "schedule", scheduleArgument + ); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null, null, + new AlarmConditionValue<>(null, "schedule")) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + String schedule = """ + {"timezone":"Europe/Kiev","items":[{"enabled":false,"dayOfWeek":1,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":2,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":3,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":4,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":5,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":6,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":7,"startsOn":0,"endsOn":0}]} + """; + postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"schedule\":" + schedule + "}"); + postTelemetry(deviceId, "{\"temperature\":50}"); + + Thread.sleep(1000); + assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + + schedule = schedule.replace("\"enabled\":false", "\"enabled\":true"); + postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"schedule\":" + schedule + "}"); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + // checking multiple debug events due to scheduled reevaluation (which also produces debug events) + CalculatedFieldDebugEvent debugEvent = getDebugEvents(calculatedField.getId(), 5).stream() + .filter(event -> event.getResult() != null) + .findFirst().orElse(null); + assertThat(debugEvent).isNotNull(); + TbAlarmResult alarmResult = JacksonUtil.fromString(debugEvent.getResult(), TbAlarmResult.class); + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + @Test + public void testChangeAlarmType() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + + postTelemetry(deviceId, "{\"temperature\":50}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + + calculatedField.setName("New alarm type"); + calculatedField = saveCalculatedField(calculatedField); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + @Test + public void testChangeRuleExpression() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 100;", null, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + + postTelemetry(deviceId, "{\"temperature\":50}"); + Thread.sleep(1000); + assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + + AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration(); + ((TbelAlarmConditionExpression) configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition().getExpression()) + .setExpression("return temperature >= 50;"); + calculatedField = saveCalculatedField(calculatedField); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + @Test + public void testChangeRequiredEventsCountForRepeatingCondition() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + int eventsCountMajor = 5; + int eventsCountCritical = 10; + Map createRules = Map.of( + AlarmSeverity.MAJOR, new Condition("return temperature >= 50;", eventsCountMajor, null), + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", eventsCountCritical, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + for (int i = 0; i < eventsCountMajor; i++) { + postTelemetry(deviceId, "{\"temperature\":50}"); + Thread.sleep(10); + } + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + assertThat(alarmResult.getConditionRepeats()).isEqualTo(5); + }); + + postTelemetry(deviceId, "{\"temperature\":50}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isUpdated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); + assertThat(alarmResult.getConditionRepeats()).isEqualTo(6); + }); + + // decreasing required events count for critical rule + AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration(); + ((RepeatingAlarmCondition) configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition()) + .setCount(new AlarmConditionValue<>(6, null)); + calculatedField = saveCalculatedField(calculatedField); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isSeverityUpdated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + assertThat(alarmResult.getConditionRepeats()).isEqualTo(6); + }); + } + + @Test + public void testChangeConditionArgumentSource() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + + Argument temperatureThresholdArgument = new Argument(); + temperatureThresholdArgument.setRefEntityKey(new ReferencedEntityKey("temperatureThreshold", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + temperatureThresholdArgument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); + temperatureThresholdArgument.setDefaultValue("100"); + loginSysAdmin(); + postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"temperatureThreshold\":100}"); + loginTenantAdmin(); + postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"temperatureThreshold\":50}"); + + Map arguments = Map.of( + "temperature", temperatureArgument, + "temperatureThreshold", temperatureThresholdArgument + ); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= temperatureThreshold;", null, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + + postTelemetry(deviceId, "{\"temperature\":50}"); + Thread.sleep(1000); + // not created because tenant's threshold 100 is used + assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + + ((AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration()).getArguments().get("temperatureThreshold") + .setRefDynamicSourceConfiguration(null); + // using threshold 50 on device level + calculatedField = saveCalculatedField(calculatedField); + + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + @Test + public void testAlarmDetails() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Argument humidityArgument = new Argument(); + humidityArgument.setRefEntityKey(new ReferencedEntityKey("humidity", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + humidityArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument, + "humidity", humidityArgument + ); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50 && humidity >= 50;", null, null) + ); + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature and Humidity Alarm", + arguments, createRules, null, configuration -> { + configuration.getCreateRules().get(AlarmSeverity.CRITICAL).setAlarmDetails( + "temperature is ${temperature}, humidity is ${humidity}" + ); + }); + + postTelemetry(deviceId, "{\"temperature\":50}"); + postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"humidity\":50}"); + + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getDetails().get("data").asText()) + .isEqualTo("temperature is 50, humidity is 50"); + }); + + ((AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration()).getCreateRules().get(AlarmSeverity.CRITICAL).setAlarmDetails( + "UPDATED temperature is ${temperature}, humidity is ${humidity}" + ); + calculatedField = saveCalculatedField(calculatedField); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isFalse(); + assertThat(alarmResult.isUpdated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getDetails().get("data").asText()) + .isEqualTo("UPDATED temperature is 50, humidity is 50"); + }); + } + + @Test + public void testCreateAlarm_scheduleStarted() throws Exception { + Argument parkingSpotOccupiedArgument = new Argument(); + parkingSpotOccupiedArgument.setRefEntityKey(new ReferencedEntityKey("parkingSpotOccupied", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + parkingSpotOccupiedArgument.setDefaultValue("false"); + Map arguments = Map.of( + "parkingSpotOccupied", parkingSpotOccupiedArgument + ); + + SpecificTimeSchedule schedule = new SpecificTimeSchedule(); + schedule.setTimezone(ZoneId.systemDefault().getId()); + schedule.setDaysOfWeek(Set.of(1, 2, 3, 4, 5, 6, 7)); + long startsOn = Duration.between(LocalDate.now().atStartOfDay(), LocalDateTime.now()) + .plus(15, ChronoUnit.SECONDS).toMillis(); + schedule.setStartsOn(startsOn); + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return parkingSpotOccupied == true;", null, null, null, + new AlarmConditionValue<>(schedule, null)) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "Illegal parking alarm", + arguments, createRules, null); + + postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"parkingSpotOccupied\":true}"); + + Thread.sleep(10000); + assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + CalculatedFieldDebugEvent debugEvent = getDebugEvents(calculatedField.getId(), 5).stream() + .filter(event -> event.getResult() != null) + .findFirst().orElse(null); + assertThat(debugEvent).isNotNull(); + TbAlarmResult alarmResult = JacksonUtil.fromString(debugEvent.getResult(), TbAlarmResult.class); + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + @Test + public void testManualClearAlarm() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + + postTelemetry(deviceId, "{\"temperature\":50}"); + Alarm alarm = checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }).getAlarm(); + + doPost("/api/alarm/" + alarm.getId() + "/clear", AlarmInfo.class); + Thread.sleep(1000); + postTelemetry(deviceId, "{\"temperature\":50}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.getAlarm().getId()).isNotEqualTo(alarm.getId()); + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + // TODO: MSA tests + + private TbAlarmResult checkAlarmResult(CalculatedField calculatedField, Consumer assertion) { + return checkAlarmResult(calculatedField, null, assertion); + } + + private TbAlarmResult checkAlarmResult(CalculatedField calculatedField, + Predicate waitFor, + Consumer assertion) { + TbAlarmResult alarmResult = await().atMost(TIMEOUT, TimeUnit.SECONDS) + .until(() -> getLatestAlarmResult(calculatedField.getId()), result -> + result != null && (waitFor == null || waitFor.test(result))); + assertion.accept(alarmResult); + + Alarm alarm = alarmResult.getAlarm(); + assertThat(alarm.getOriginator()).isEqualTo(originatorId); + assertThat(alarm.getType()).isEqualTo(calculatedField.getName()); + return alarmResult; + } + + private TbAlarmResult getLatestAlarmResult(CalculatedFieldId calculatedFieldId) { + List debugEvents = getDebugEvents(calculatedFieldId, 1); + if (debugEvents.isEmpty()) { + return null; + } + CalculatedFieldDebugEvent debugEvent = debugEvents.get(0); + if (debugEvent.getError() != null) { + throw new RuntimeException(debugEvent.getError()); + } + if (debugEvent.getId().equals(latestEventId)) { + return null; + } + latestEventId = debugEvent.getId(); + return JacksonUtil.fromString(debugEvent.getResult(), TbAlarmResult.class); + } + + private CalculatedField createAlarmCf(EntityId entityId, + String alarmType, + Map arguments, + Map createConditions, + Condition clearCondition, + Consumer... modifier) { + Map createRules = new HashMap<>(); + createConditions.forEach((severity, condition) -> { + createRules.put(severity, toAlarmRule(condition)); + }); + AlarmRule clearRule = clearCondition != null ? toAlarmRule(clearCondition) : null; + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(entityId); + calculatedField.setName(alarmType); + calculatedField.setType(CalculatedFieldType.ALARM); + AlarmCalculatedFieldConfiguration configuration = new AlarmCalculatedFieldConfiguration(); + configuration.setArguments(arguments); + configuration.setCreateRules(createRules); + configuration.setClearRule(clearRule); + calculatedField.setConfiguration(configuration); + calculatedField.setDebugSettings(DebugSettings.all()); + if (modifier.length > 0) { + modifier[0].accept(configuration); + } + CalculatedField savedCalculatedField = saveCalculatedField(calculatedField); + + CalculatedFieldDebugEvent debugEvent = await().atMost(TIMEOUT, TimeUnit.SECONDS) + .until(() -> getDebugEvents(savedCalculatedField.getId(), 1), + events -> !events.isEmpty()).get(0); + latestEventId = debugEvent.getId(); + return savedCalculatedField; + } + + private AlarmRule toAlarmRule(Condition condition) { + AlarmRule rule = new AlarmRule(); + AlarmConditionExpression expression; + if (condition.getTbelExpression() != null) { + TbelAlarmConditionExpression tbelExpression = new TbelAlarmConditionExpression(); + tbelExpression.setExpression(condition.getTbelExpression()); + expression = tbelExpression; + } else { + expression = condition.getSimpleExpression(); + } + if (condition.getEventsCount() != null) { + RepeatingAlarmCondition alarmCondition = new RepeatingAlarmCondition(); + alarmCondition.setExpression(expression); + alarmCondition.setCount(condition.getEventsCount()); + rule.setCondition(alarmCondition); + } else if (condition.getDuration() != null) { + DurationAlarmCondition alarmCondition = new DurationAlarmCondition(); + alarmCondition.setExpression(expression); + alarmCondition.setUnit(TimeUnit.MILLISECONDS); + alarmCondition.setValue(condition.getDuration()); + rule.setCondition(alarmCondition); + } else { + SimpleAlarmCondition alarmCondition = new SimpleAlarmCondition(); + alarmCondition.setExpression(expression); + rule.setCondition(alarmCondition); + } + if (condition.getSchedule() != null) { + rule.getCondition().setSchedule(condition.getSchedule()); + } + return rule; + } + + private SimpleAlarmConditionExpression createSimpleExpression(String argument, StringOperation stringOperation, AlarmConditionValue conditionValue) { + SimpleAlarmConditionExpression simpleExpression = new SimpleAlarmConditionExpression(); + AlarmConditionFilter filter = new AlarmConditionFilter(); + filter.setArgument(argument); + filter.setValueType(EntityKeyValueType.STRING); + StringFilterPredicate predicate = new StringFilterPredicate(); + predicate.setOperation(stringOperation); + predicate.setValue(conditionValue); + filter.setPredicates(List.of(predicate)); + simpleExpression.setFilters(List.of(filter)); + return simpleExpression; + } + + private List getDebugEvents(CalculatedFieldId calculatedFieldId, int limit) { + return eventDao.findLatestEvents(tenantId.getId(), calculatedFieldId.getId(), EventType.DEBUG_CALCULATED_FIELD, limit).stream() + .map(e -> (CalculatedFieldDebugEvent) e).toList(); + } + + @Getter + @AllArgsConstructor + private static final class Condition { + + private final String tbelExpression; + private final SimpleAlarmConditionExpression simpleExpression; + private AlarmConditionValue eventsCount; + private AlarmConditionValue duration; + private AlarmConditionValue schedule; + + private Condition(String tbelExpression, Integer eventsCount, Long durationMs) { + this.tbelExpression = tbelExpression; + this.simpleExpression = null; + if (eventsCount != null) { + this.eventsCount = new AlarmConditionValue<>(eventsCount, null); + } + if (durationMs != null) { + this.duration = new AlarmConditionValue<>(durationMs, null); + } + } + + private Condition(SimpleAlarmConditionExpression simpleExpression, Integer eventsCount, Long durationMs) { + this.tbelExpression = null; + this.simpleExpression = simpleExpression; + if (eventsCount != null) { + this.eventsCount = new AlarmConditionValue<>(eventsCount, null); + } + if (durationMs != null) { + this.duration = new AlarmConditionValue<>(durationMs, null); + } + } + + } + +} diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldCurrentOwnerTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldCurrentOwnerTest.java new file mode 100644 index 0000000000..d2f9621064 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldCurrentOwnerTest.java @@ -0,0 +1,200 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Test; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.CurrentOwnerDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class CalculatedFieldCurrentOwnerTest extends AbstractControllerTest { + + public static final int TIMEOUT = 60; + public static final int POLL_INTERVAL = 1; + + @Test + public void testCreateCFWithCurrentOwner() throws Exception { + loginTenantAdmin(); + + postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":5}"); + + Device testDevice = createDevice("Test device", "1234567890"); + + doPost("/api/customer/" + customerId.getId() + "/device/" + testDevice.getId().getId()).andExpect(status().isOk()); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", buildCalculatedField(testDevice.getId()), CalculatedField.class); + + await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("105"); + }); + + postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":10}"); + + await().alias("update telemetry -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("110"); + }); + } + + @Test + public void testChangeOwner() throws Exception { + loginSysAdmin(); + + postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":50}"); + + loginTenantAdmin(); + + postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":5}"); + Device testDevice = createDevice("Test device", "1234567890"); + doPost("/api/customer/" + customerId.getId() + "/device/" + testDevice.getId().getId()).andExpect(status().isOk()); + + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", buildCalculatedField(testDevice.getId()), CalculatedField.class); + + await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("105"); + }); + + doDelete("/api/customer/device/" + testDevice.getId().getId()).andExpect(status().isOk()); + + await().alias("change owner -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("150"); + }); + } + + @Test + public void testCreateCFWithCurrentOwnerWhenEntityIsProfile() throws Exception { + loginSysAdmin(); + + postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":50}"); + + loginTenantAdmin(); + + postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":5}"); + + AssetProfile assetProfile = doPost("/api/assetProfile", createAssetProfile("Test Asset Profile"), AssetProfile.class); + + Asset asset1 = createAsset("Test asset 1", assetProfile.getId()); + doPost("/api/customer/" + customerId.getId() + "/asset/" + asset1.getId().getId()).andExpect(status().isOk()); + + Asset asset2 = createAsset("Test asset 2", assetProfile.getId()); // owner - TENANT + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", buildCalculatedField(assetProfile.getId()), CalculatedField.class); + + await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + // result of asset 1 + ObjectNode result1 = getLatestTelemetry(asset1.getId(), "result"); + assertThat(result1).isNotNull(); + assertThat(result1.get("result").get(0).get("value").asText()).isEqualTo("105"); + + // result of asset 2 + ObjectNode result2 = getLatestTelemetry(asset2.getId(), "result"); + assertThat(result2).isNotNull(); + assertThat(result2.get("result").get(0).get("value").asText()).isEqualTo("150"); + }); + + doPost("/api/customer/" + customerId.getId() + "/asset/" + asset2.getId().getId()).andExpect(status().isOk()); + + await().alias("change asset2 owner -> recalculate state for asset 2").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + // result of asset 2 + ObjectNode result2 = getLatestTelemetry(asset2.getId(), "result"); + assertThat(result2).isNotNull(); + assertThat(result2.get("result").get(0).get("value").asText()).isEqualTo("105"); + }); + } + + private CalculatedField buildCalculatedField(EntityId entityId) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(entityId); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("Test Calculated Field"); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("attrKey", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE); + argument.setRefEntityKey(refEntityKey); + argument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); + + config.setArguments(Map.of("a", argument)); + + config.setExpression("a + 100"); + + Output output = new Output(); + output.setName("result"); + output.setType(OutputType.TIME_SERIES); + output.setDecimalsByDefault(0); + + config.setOutput(output); + + calculatedField.setConfiguration(config); + return calculatedField; + } + + private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { + return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); + } + + private Asset createAsset(String name, AssetProfileId assetProfileId) { + Asset asset = new Asset(); + asset.setName(name); + asset.setAssetProfileId(assetProfileId); + return doPost("/api/asset", asset, Asset.class); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index b6a1faa1ed..b6b006b16c 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -17,14 +17,15 @@ package org.thingsboard.server.cf; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Test; -import org.junit.jupiter.api.BeforeEach; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityInfo; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -35,8 +36,9 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; -import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; @@ -47,10 +49,12 @@ import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.controller.CalculatedFieldControllerTest; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -67,15 +71,10 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes public static final int TIMEOUT = 60; public static final int POLL_INTERVAL = 1; - @BeforeEach - void setUp() throws Exception { - loginTenantAdmin(); - } - @Test public void testSimpleCalculatedFieldWhenAllTelemetryPresent() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":25}")); + postTelemetry(testDevice.getId(), "{\"temperature\":25}"); doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"deviceTemperature\":40}")); CalculatedField calculatedField = new CalculatedField(); @@ -112,7 +111,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0"); }); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":30}")); + postTelemetry(testDevice.getId(), "{\"temperature\":30}"); await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -133,6 +132,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes .untilAsserted(() -> { ArrayNode temperatureF = getServerAttributes(testDevice.getId(), "temperatureF"); assertThat(temperatureF).isNotNull(); + assertThat(temperatureF.get(0)).isNotNull(); assertThat(temperatureF.get(0).get("value").asText()).isEqualTo("86.0"); }); @@ -197,7 +197,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").isNull()).isTrue(); }); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":30}")); + postTelemetry(testDevice.getId(), "{\"temperature\":30}"); await().alias("update telemetry -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -246,7 +246,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("53.6"); }); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":30}")); + postTelemetry(testDevice.getId(), "{\"temperature\":30}"); await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -431,7 +431,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes @Test public void testSimpleCalculatedFieldWhenExpressionIsInvalid() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":25}")); + postTelemetry(testDevice.getId(), "{\"temperature\":25}"); CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(testDevice.getId()); @@ -467,7 +467,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").isNull()).isTrue(); }); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":30}")); + postTelemetry(testDevice.getId(), "{\"temperature\":30}"); await().alias("update telemetry -> ctx is not initialized -> no calculation perform").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -482,7 +482,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes public void testSimpleCalculatedFieldWhenUseLatestTsIsTrue() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); long ts = System.currentTimeMillis() - 300000L; - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts))); + postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts)); CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(testDevice.getId()); @@ -526,10 +526,10 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes long ts = System.currentTimeMillis(); long tsA = ts - 300000L; - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"a\":1}}", tsA))); + postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"a\":1}}", tsA)); long tsB = ts - 300L; - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"b\":5}}", tsB))); + postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"b\":5}}", tsB)); CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(testDevice.getId()); @@ -570,7 +570,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes }); long tsABeforeTsB = tsB - 300L; - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"a\":10}}", tsABeforeTsB))); + postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"a\":10}}", tsABeforeTsB)); await().alias("update telemetry with ts less than latest -> save result with latest ts").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -586,7 +586,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes public void testScriptCalculatedFieldWhenUsedLatestTsInScript() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); long ts = System.currentTimeMillis() - 300000L; - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts))); + postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts)); CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(testDevice.getId()); @@ -621,6 +621,59 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes }); } + @Test + public void testSimpleCalculatedFieldWhenCtxBecameUninitialized() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("M + 1"); + calculatedField.setDebugSettings(DebugSettings.all()); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("m", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + config.setArguments(Map.of("m", argument)); + config.setExpression("m + 1"); + + Output output = new Output(); + output.setName("m1"); + output.setType(OutputType.TIME_SERIES); + output.setDecimalsByDefault(0); + config.setOutput(output); + + calculatedField.setConfiguration(config); + + calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"m\":1}")); + + await().alias("create CF -> ctx is initialized -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode m1 = getLatestTelemetry(testDevice.getId(), "m1"); + assertThat(m1).isNotNull(); + assertThat(m1.get("m1").get(0).get("value").asText()).isEqualTo("2"); + }); + + config.setExpression("m m"); + calculatedField.setConfiguration(config); + calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"m\":2}")); + + await().alias("update CF -> ctx is not initialized -> no calculation performed").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode m1 = getLatestTelemetry(testDevice.getId(), "m1"); + assertThat(m1).isNotNull(); + assertThat(m1.get("m1").get(0).get("value").asText()).isEqualTo("2"); + }); + } + @Test public void testGeofencingCalculatedField_withZonesCreatedOnDevice() throws Exception { // --- Arrange entities --- @@ -760,19 +813,13 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes // Zone groups: ATTRIBUTE on specific assets (one zone per group) ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - var allowedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - allowedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM); - allowedZoneDynamicSourceConfiguration.setRelationType("AllowedZone"); - allowedZoneDynamicSourceConfiguration.setMaxLevel(1); - allowedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true); + var allowedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "AllowedZone"))); allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration); ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - var restrictedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - restrictedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM); - restrictedZoneDynamicSourceConfiguration.setRelationType("RestrictedZone"); - restrictedZoneDynamicSourceConfiguration.setMaxLevel(1); - restrictedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true); + var restrictedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + restrictedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "RestrictedZone"))); restrictedZonesGroup.setRefDynamicSourceConfiguration(restrictedZoneDynamicSourceConfiguration); cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup)); @@ -831,10 +878,11 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes TenantProfile foundTenantProfile = doGet("/api/tenantProfile/" + tenantProfileEntityInfo.getId().getId().toString(), TenantProfile.class); assertThat(foundTenantProfile).isNotNull(); assertThat(foundTenantProfile.getDefaultProfileConfiguration()).isNotNull(); - foundTenantProfile.getDefaultProfileConfiguration().setMinAllowedScheduledUpdateIntervalInSecForCF(TIMEOUT / 10); + int minAllowedScheduledUpdateIntervalInSecForCF = TIMEOUT / 10; + foundTenantProfile.getDefaultProfileConfiguration().setMinAllowedScheduledUpdateIntervalInSecForCF(minAllowedScheduledUpdateIntervalInSecForCF); TenantProfile savedTenantProfile = doPost("/api/tenantProfile", foundTenantProfile, TenantProfile.class); assertThat(savedTenantProfile).isNotNull(); - assertThat(savedTenantProfile.getDefaultProfileConfiguration().getMinAllowedScheduledUpdateIntervalInSecForCF()).isEqualTo(TIMEOUT / 10); + assertThat(savedTenantProfile.getDefaultProfileConfiguration().getMinAllowedScheduledUpdateIntervalInSecForCF()).isEqualTo(minAllowedScheduledUpdateIntervalInSecForCF); loginTenantAdmin(); // --- Arrange entities --- @@ -869,11 +917,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY)); var allowedZonesGroup = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - var allowedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - allowedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM); - allowedZoneDynamicSourceConfiguration.setRelationType("AllowedZone"); - allowedZoneDynamicSourceConfiguration.setMaxLevel(1); - allowedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true); + var allowedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "AllowedZone"))); allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration); cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup)); @@ -884,7 +929,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes cfg.setOutput(out); // Enable scheduled refresh with a 6-second interval - cfg.setScheduledUpdateInterval(6); + cfg.setScheduledUpdateInterval(minAllowedScheduledUpdateIntervalInSecForCF); + cfg.setScheduledUpdateEnabled(true); cf.setConfiguration(cfg); CalculatedField savedCalculatedField = doPost("/api/calculatedField", cf, CalculatedField.class); @@ -935,7 +981,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes relAllowedB.setType("AllowedZone"); doPost("/api/relation", relAllowedB).andExpect(status().isOk()); - awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsDirty(device.getId(), savedCalculatedField.getId()); + awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsReadyToRefreshDynamicArguments(device.getId(), savedCalculatedField.getId(), minAllowedScheduledUpdateIntervalInSecForCF); // --- Same coordinates as before, but now we expect ENTERED since a new zone is registered --- doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope", @@ -954,6 +1000,216 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes }); } + @Test + public void testPropagationCalculatedField_withExpression() throws Exception { + // --- Arrange entities --- + Device device = createDevice("Propagation Device With Expression", "sn-prop-1"); + Asset asset1 = createAsset("Propagated Asset 1", null); + Asset asset2 = createAsset("Propagated Asset 2", null); + + // Create relations FROM assets TO device + EntityRelation rel1 = new EntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + doPost("/api/relation", rel1).andExpect(status().isOk()); + doPost("/api/relation", rel2).andExpect(status().isOk()); + + // Telemetry on device + doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope", + JacksonUtil.toJsonNode("{\"temperature\":12.5}")).andExpect(status().isOk()); + + // --- Build CF: PROPAGATION with expression --- + CalculatedField cf = new CalculatedField(); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.PROPAGATION); + cf.setName("Propagation CF (expr)"); + cf.setConfigurationVersion(1); + + PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.TO, EntityRelation.CONTAINS_TYPE)); + cfg.setApplyExpressionToResolvedArguments(true); + + Argument arg = new Argument(); + arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("t", arg)); + + cfg.setExpression("{\"testResult\": t * 2}"); + + Output output = new Output(); + output.setType(OutputType.ATTRIBUTES); + output.setScope(AttributeScope.SERVER_SCOPE); + cfg.setOutput(output); + + cf.setConfiguration(cfg); + + doPost("/api/calculatedField", cf, CalculatedField.class); + + // --- Assert propagated calculation (expression applied) --- + await().alias("propagation expr mode evaluation") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs1 = getServerAttributes(asset1.getId(), "testResult"); + ArrayNode attrs2 = getServerAttributes(asset2.getId(), "testResult"); + assertThat(attrs1).isNotNull(); + assertThat(attrs2).isNotNull(); + assertThat(attrs1.get(0).get("value").asDouble()).isEqualTo(25.0); + assertThat(attrs2.get(0).get("value").asDouble()).isEqualTo(25.0); + }); + + String deleteUrl = String.format("/api/v2/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + asset1.getId().getId(), EntityType.ASSET, + EntityRelation.CONTAINS_TYPE, device.getId().getId(), EntityType.DEVICE + ); + doDelete(deleteUrl).andExpect(status().isOk()); + doDelete("/api/plugins/telemetry/ASSET/" + asset1.getId() + "/SERVER_SCOPE?keys=testResult").andExpect(status().isOk()); + + doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope", + JacksonUtil.toJsonNode("{\"temperature\":25}")).andExpect(status().isOk()); + + // --- Assert propagated calculation (expression applied with new temperature argument and one relation removed) --- + await().alias("propagation expr mode evaluation after temperature update") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs1 = getServerAttributes(asset1.getId(), "testResult"); + ArrayNode attrs2 = getServerAttributes(asset2.getId(), "testResult"); + assertThat(attrs1).isNullOrEmpty(); + assertThat(attrs2).isNotNull(); + assertThat(attrs2.get(0).get("value").asDouble()).isEqualTo(50); + }); + } + + @Test + public void testPropagationCalculatedField_withoutExpression() throws Exception { + // --- Arrange entities --- + Device device = createDevice("Propagation Device Without Expression", "sn-prop-2"); + Asset asset1 = createAsset("Propagated Asset 1", null); + Asset asset2 = createAsset("Propagated Asset 2", null); + + // Create relations FROM assets TO device + EntityRelation rel1 = new EntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + doPost("/api/relation", rel1).andExpect(status().isOk()); + doPost("/api/relation", rel2).andExpect(status().isOk()); + + // Telemetry on device + long ts = System.currentTimeMillis() - 300000L; + postTelemetry(device.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":12.5}}", ts)); + + // --- Build CF: PROPAGATION without expression --- + CalculatedField cf = new CalculatedField(); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.PROPAGATION); + cf.setName("Propagation CF (args-only)"); + cf.setConfigurationVersion(1); + + PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.TO, EntityRelation.CONTAINS_TYPE)); + cfg.setApplyExpressionToResolvedArguments(false); // arguments-only mode + + Argument arg = new Argument(); + arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("temperatureComputed", arg)); + + Output output = new Output(); + output.setType(OutputType.TIME_SERIES); + cfg.setOutput(output); + + cf.setConfiguration(cfg); + + doPost("/api/calculatedField", cf, CalculatedField.class); + + // --- Assert propagated calculation (arguments-only mode) --- + await().alias("propagation args-only evaluation") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode telemetry1 = getLatestTelemetry(asset1.getId(), "temperatureComputed"); + ObjectNode telemetry2 = getLatestTelemetry(asset2.getId(), "temperatureComputed"); + assertThat(telemetry1).isNotNull(); + assertThat(telemetry2).isNotNull(); + assertThat(telemetry1.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(telemetry1.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(12.5); + assertThat(telemetry2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(telemetry2.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(12.5); + }); + + String deleteUrl = String.format("/api/v2/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + asset1.getId().getId(), EntityType.ASSET, + EntityRelation.CONTAINS_TYPE, device.getId().getId(), EntityType.DEVICE + ); + doDelete(deleteUrl).andExpect(status().isOk()); + doDelete("/api/plugins/telemetry/ASSET/" + asset1.getId() + "/timeseries/delete?keys=temperatureComputed&deleteAllDataForKeys=true").andExpect(status().isOk()); + + // Update telemetry on device + long newTs = System.currentTimeMillis() - 300000L; + postTelemetry(device.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":25}}", newTs)); + + // --- Assert propagated calculation (arguments-only mode after update) --- + await().alias("propagation args-only evaluation after temperature update") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode telemetry1 = getLatestTelemetry(asset1.getId(), "temperatureComputed"); + ObjectNode telemetry2 = getLatestTelemetry(asset2.getId(), "temperatureComputed"); + assertThat(telemetry1).isNotNull(); + assertThat(telemetry2).isNotNull(); + assertThat(telemetry1.get("temperatureComputed").get(0).get("value")).isEqualTo(NullNode.instance); + assertThat(telemetry2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs)); + assertThat(telemetry2.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(25); + }); + } + + @Test + public void testCalculatedFieldWhenTheSameTelemetryKeysUsed() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"a\":5}")); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("a + b"); + calculatedField.setDebugSettings(DebugSettings.all()); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("a", ArgumentType.TS_LATEST, null); + Argument argumentA = new Argument(); + argumentA.setRefEntityKey(refEntityKey); + Argument argumentB = new Argument(); + argumentB.setRefEntityKey(refEntityKey); + config.setArguments(Map.of("a", argumentA, "b", argumentB)); + config.setExpression("a + b"); + + Output output = new Output(); + output.setName("c"); + output.setType(OutputType.TIME_SERIES); + output.setDecimalsByDefault(0); + config.setOutput(output); + + calculatedField.setConfiguration(config); + + doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode c = getLatestTelemetry(testDevice.getId(), "c"); + assertThat(c).isNotNull(); + assertThat(c.get("c").get(0).get("value").asText()).isEqualTo("10"); + }); + + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"a\":10}")); + + await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode c = getLatestTelemetry(testDevice.getId(), "c"); + assertThat(c).isNotNull(); + assertThat(c.get("c").get(0).get("value").asText()).isEqualTo("20"); + }); + } + private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); } diff --git a/application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java b/application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java new file mode 100644 index 0000000000..48c4e67608 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java @@ -0,0 +1,846 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.test.annotation.DirtiesContext; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.debug.DebugSettings; +import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration; +import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; +import org.thingsboard.server.common.data.device.data.DeviceData; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.cf.CalculatedFieldIntegrationTest.POLL_INTERVAL; + +@DaoSqlTest +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +public class RelatedEntitiesAggregationCalculatedFieldTest extends AbstractControllerTest { + + private Tenant savedTenant; + + private DeviceProfile deviceProfile; + private Device device1; + private String accessToken1 = "1234567890111"; + private Device device2; + private String accessToken2 = "1234567890222"; + + private AssetProfile assetProfile; + private Asset asset; + + private final long deduplicationInterval = 5; + + @Before + public void beforeEach() throws Exception { + loginSysAdmin(); + + updateDefaultTenantProfileConfig(tenantProfileConfig -> { + tenantProfileConfig.setMinAllowedDeduplicationIntervalInSecForCF(1); + tenantProfileConfig.setMinAllowedScheduledUpdateIntervalInSecForCF(1); + }); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = saveTenant(tenant); + assertThat(savedTenant).isNotNull(); + + User tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant@thingsboard.org"); + tenantAdmin.setFirstName("John"); + tenantAdmin.setLastName("Doe"); + + createUserAndLogin(tenantAdmin, "testPassword"); + + deviceProfile = doPost("/api/deviceProfile", createDeviceProfile("Device Profile"), DeviceProfile.class); + device1 = createDevice("Device 1", deviceProfile.getId(), accessToken1); + device2 = createDevice("Device 2", deviceProfile.getId(), accessToken2); + + postTelemetry(device1.getId(), "{\"occupied\":true}"); + postTelemetry(device2.getId(), "{\"occupied\":false}"); + + assetProfile = doPost("/api/assetProfile", createAssetProfile("Asset Profile"), AssetProfile.class); + asset = createAsset("Asset", assetProfile.getId()); + + createEntityRelation(asset.getId(), device1.getId(), "Contains"); + createEntityRelation(asset.getId(), device2.getId(), "Contains"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + deleteTenant(savedTenant.getId()); + } + + @Test + public void testCreateCfOnProfile_checkInitialAggregation() throws Exception { + Asset asset2 = createAsset("Asset 2", assetProfile.getId()); + Device device3 = createDevice("Device 3", "1234567890333"); + Device device4 = createDevice("Device 4", "1234567890444"); + + createEntityRelation(asset2.getId(), device3.getId(), "Contains"); + createEntityRelation(asset2.getId(), device4.getId(), "Contains"); + + createOccupancyCF(assetProfile.getId()); + + await().alias("create CF and perform initial aggregation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "1", + "totalSpaces", "2" + )); + + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + + postTelemetry(device3.getId(), "{\"occupied\":true}"); + + await().alias("update telemetry and perform aggregation") + .atLeast(deduplicationInterval / 2, TimeUnit.SECONDS) + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "1", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testAddEntityToProfile_checkAggregation() throws Exception { + createOccupancyCF(assetProfile.getId()); + + Device device3 = createDevice("Device 3", "1234567890333"); + Device device4 = createDevice("Device 4", "1234567890444"); + postTelemetry(device3.getId(), "{\"occupied\":true}"); + postTelemetry(device4.getId(), "{\"occupied\":true}"); + + Asset asset2 = createAsset("Asset 2", assetProfile.getId()); + + await().alias("add entity to profile with no related entities and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode occupancy = getLatestTelemetry(asset2.getId(), "freeSpaces", "occupiedSpaces", "totalSpaces"); + assertThat(occupancy).isNotNull(); + assertThat(occupancy.get("freeSpaces").get(0).get("value").isNull()).isTrue(); + assertThat(occupancy.get("occupiedSpaces").get(0).get("value").isNull()).isTrue(); + assertThat(occupancy.get("totalSpaces").get(0).get("value").isNull()).isTrue(); + }); + + createEntityRelation(asset2.getId(), device3.getId(), "Contains"); + createEntityRelation(asset2.getId(), device4.getId(), "Contains"); + + await().alias("create relations and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "0", + "occupiedSpaces", "2", + "totalSpaces", "2" + )); + }); + + postTelemetry(device3.getId(), "{\"occupied\":false}"); + + await().alias("update telemetry and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "1", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testChangeEntityProfile_checkAggregation() throws Exception { + Asset asset2 = createAsset("Asset 2", assetProfile.getId()); + Device device3 = createDevice("Device 3", "1234567890333"); + Device device4 = createDevice("Device 4", "1234567890444"); + + createEntityRelation(asset2.getId(), device3.getId(), "Contains"); + createEntityRelation(asset2.getId(), device4.getId(), "Contains"); + + createOccupancyCF(assetProfile.getId()); + + await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "1", + "totalSpaces", "2" + )); + + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + + AssetProfile newAssetProfile = createAssetProfile("New Asset Profile"); + asset2.setAssetProfileId(newAssetProfile.getId()); + doPost("/api/asset", asset2, Asset.class); + + postTelemetry(device3.getId(), "{\"occupied\":true}"); + + await().alias("change profile and no aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testCreateCfOnAssetAndNoTelemetryOnDevices_checkDefaultValueUsed() throws Exception { + Asset asset2 = createAsset("Asset 2", assetProfile.getId()); + Device device3 = createDevice("Device 3", "1234567890333"); + Device device4 = createDevice("Device 4", "1234567890444"); + + createEntityRelation(asset2.getId(), device3.getId(), "Contains"); + createEntityRelation(asset2.getId(), device4.getId(), "Contains"); + + createOccupancyCF(asset2.getId()); + + await().alias("create CF and perform aggregation with default values").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testCreateCfAndUpdateTelemetry_checkAggregation() throws Exception { + createOccupancyCF(asset.getId()); + checkInitialCalculation(); + + postTelemetry(device1.getId(), "{\"occupied\":false}"); + + await().alias("update telemetry and perform aggregation") + .atLeast(deduplicationInterval / 2, TimeUnit.SECONDS) + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testCreateCfAndRelationToRuleChain_checkAggregation() throws Exception { + Asset asset2 = createAsset("Asset 2", assetProfile.getId()); + Device device3 = createDevice("Device 3", "1234567890333"); + postTelemetry(device3.getId(), "{\"occupied\":true}"); + + RuleChain ruleChain = new RuleChain(); + ruleChain.setName("RuleChain"); + ruleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); + postTelemetry(ruleChain.getId(), "{\"occupied\":true}"); + + createEntityRelation(asset2.getId(), device3.getId(), "Contains"); + createEntityRelation(asset2.getId(), ruleChain.getId(), "Contains"); + + createOccupancyCF(asset2.getId()); + + await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "0", + "occupiedSpaces", "1", + "totalSpaces", "1" + )); + }); + + postTelemetry(ruleChain.getId(), "{\"occupied\":true}"); + + await().alias("update telemetry on rule chain and no aggregation performed").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "0", + "occupiedSpaces", "1", + "totalSpaces", "1" + )); + }); + } + + @Test + public void testDeleteCf_checkNoAggregation() throws Exception { + CalculatedField cf = createOccupancyCF(asset.getId()); + checkInitialCalculation(); + + doDelete("/api/calculatedField/" + cf.getId().getId().toString()) + .andExpect(status().isOk()); + + postTelemetry(device1.getId(), "{\"occupied\":false}"); + + await().alias("delete cf and update telemetry and no aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "1", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testUpdateTelemetry_checkAggregationNotExecutedUntilDeduplicationInterval() throws Exception { + createOccupancyCF(asset.getId()); + checkInitialCalculation(); + + postTelemetry(device1.getId(), "{\"occupied\":false}"); + + await().alias("update telemetry -> no changes").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(this::checkInitialCalculationValues); + + postTelemetry(device2.getId(), "{\"occupied\":false}"); + + await().alias("create CF and perform initial calculation") + .atLeast(deduplicationInterval / 2, TimeUnit.SECONDS) + .atMost(TIMEOUT, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testDeleteTelemetry_checkAggregationWithPreviousValuesOrDefault() throws Exception { + Asset asset2 = createAsset("Asset 2", assetProfile.getId()); + Device device3 = createDevice("Device 3", "1234567890333"); + Device device4 = createDevice("Device 4", "1234567890444"); + + createEntityRelation(asset2.getId(), device3.getId(), "Contains"); + createEntityRelation(asset2.getId(), device4.getId(), "Contains"); + + long currentTime = System.currentTimeMillis(); + long firstTs = currentTime - 10; + long secondTs = currentTime - 10; + long thirdTs = currentTime - 5; + postTelemetry(device3.getId(), "{\"ts\": " + firstTs + ", \"values\": {\"occupied\":true}}"); + postTelemetry(device4.getId(), "{\"ts\": " + secondTs + ", \"values\": {\"occupied\":true}}"); + postTelemetry(device3.getId(), "{\"ts\": " + thirdTs + ", \"values\": {\"occupied\":true}}"); + + createOccupancyCF(asset2.getId()); + + await().alias("create CF and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "0", + "occupiedSpaces", "2", + "totalSpaces", "2" + )); + }); + + doDelete("/api/plugins/telemetry/DEVICE/" + device3.getId() + "/timeseries/delete?keys=occupied&deleteAllDataForKeys=false&rewriteLatestIfDeleted=true&deleteLatest=true&startTs=" + thirdTs + "&endTs=" + thirdTs + 1, String.class); + doDelete("/api/plugins/telemetry/DEVICE/" + device4.getId() + "/timeseries/delete?keys=occupied&deleteAllDataForKeys=false&rewriteLatestIfDeleted=true&deleteLatest=true&startTs=" + secondTs + "&endTs=" + secondTs + 1, String.class); + + await().alias("delete latest telemetry and perform aggregation with previous or default values").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "1", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testDeleteAttr_checkAggregationWithDefault() throws Exception { + Asset asset2 = createAsset("Asset 2", assetProfile.getId()); + Device device3 = createDevice("Device 3", "1234567890333"); + Device device4 = createDevice("Device 4", "1234567890444"); + + createEntityRelation(asset2.getId(), device3.getId(), "Contains"); + createEntityRelation(asset2.getId(), device4.getId(), "Contains"); + + postAttributes(device3.getId(), AttributeScope.SERVER_SCOPE, "{\"occupied\":true}"); + postAttributes(device4.getId(), AttributeScope.SERVER_SCOPE, "{\"occupied\":true}"); + + createOccupancyCFWithAttr(asset2.getId()); + + await().alias("create CF and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "0", + "occupiedSpaces", "2", + "totalSpaces", "2" + )); + }); + + doDelete("/api/plugins/telemetry/DEVICE/" + device3.getUuidId() + "/SERVER_SCOPE?keys=occupied", String.class); + doDelete("/api/plugins/telemetry/DEVICE/" + device4.getUuidId() + "/SERVER_SCOPE?keys=occupied", String.class); + + await().alias("delete attribute and perform aggregation with default values").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testCreateRelation_checkAggregation() throws Exception { + createOccupancyCF(asset.getId()); + checkInitialCalculation(); + + Device device3 = createDevice("Device 3", deviceProfile.getId(), "1234567890333"); + + postTelemetry(device3.getId(), "{\"occupied\":true}"); + + createEntityRelation(asset.getId(), device3.getId(), "Contains"); + + await().alias("create relation and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "2", + "totalSpaces", "3" + )); + }); + } + + @Test + public void testDeleteRelation_checkAggregation() throws Exception { + createOccupancyCF(asset.getId()); + checkInitialCalculation(); + + deleteEntityRelation(new EntityRelation(asset.getId(), device1.getId(), "Contains", RelationTypeGroup.COMMON)); + + await().alias("create relation and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "0", + "totalSpaces", "1" + )); + }); + } + + @Test + public void testDeleteEntityByRelation_checkAggregation() throws Exception { + createOccupancyCF(asset.getId()); + checkInitialCalculation(); + + doDelete("/api/device/" + device1.getId()).andExpect(status().isOk()); + + await().alias("create relation and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "0", + "totalSpaces", "1" + )); + }); + } + + @Test + public void testUpdateRelationPath_checkAggregation() throws Exception { + CalculatedField cf = createOccupancyCF(asset.getId()); + checkInitialCalculation(); + + Device device3 = createDevice("Device 3", "1234567890333"); + createEntityRelation(asset.getId(), device3.getId(), "Has"); + postTelemetry(device3.getId(), "{\"occupied\":true}"); + + var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); + configuration.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, "Has")); + saveCalculatedField(cf); + + await().alias("update relation path and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "0", + "occupiedSpaces", "1", + "totalSpaces", "1" + )); + }); + } + + @Test + public void testUpdateArguments_checkAggregation() throws Exception { + CalculatedField cf = createOccupancyCF(asset.getId()); + checkInitialCalculation(); + + postTelemetry(device1.getId(), "{\"occupiedStatus\":false}"); + postTelemetry(device2.getId(), "{\"occupiedStatus\":false}"); + + var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("oc", ArgumentType.TS_LATEST, null)); + argument.setDefaultValue("false"); + configuration.setArguments(Map.of("oc", argument)); + saveCalculatedField(cf); + + await().alias("update arguments and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testUpdateMetrics_checkAggregation() throws Exception { + postTelemetry(device1.getId(), "{\"temperature\":24.2}"); + postTelemetry(device2.getId(), "{\"temperature\":19.6}"); + CalculatedField cf = createAvgTemperatureCF(asset.getId()); + + await().alias("create avg temp cf and perform initial aggregation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24")); + }); + + var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); + AggMetric aggMetric = new AggMetric(); + aggMetric.setInput(new AggKeyInput("temp")); + aggMetric.setFilter("return temp < 100;"); + aggMetric.setFunction(AggFunction.MAX); + configuration.setMetrics(Map.of("maxTemperature", aggMetric)); + saveCalculatedField(cf); + + await().alias("update metrics and perform aggregation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of("maxTemperature", "24")); + }); + + postTelemetry(device1.getId(), "{\"temperature\":101.3}"); + postTelemetry(device2.getId(), "{\"temperature\":25.8}"); + + await().alias("update telemetry and perform aggregation") + .atLeast(deduplicationInterval / 2, TimeUnit.SECONDS) + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of("maxTemperature", "26")); + }); + } + + @Test + public void testUpdateOutput_checkAggregation() throws Exception { + postTelemetry(device1.getId(), "{\"temperature\":24.2}"); + postTelemetry(device2.getId(), "{\"temperature\":19.6}"); + CalculatedField cf = createAvgTemperatureCF(asset.getId()); + + await().alias("create avg temp cf and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24")); + }); + + var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); + Output output = new Output(); + output.setType(OutputType.ATTRIBUTES); + output.setScope(AttributeScope.SERVER_SCOPE); + configuration.setOutput(output); + saveCalculatedField(cf); + + await().alias("update output and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode avgTemperature = getServerAttributes(asset.getId(), "avgTemperature"); + assertThat(avgTemperature).isNotNull(); + assertThat(avgTemperature.get(0)).isNotNull(); + assertThat(avgTemperature.get(0).get("value").asText()).isEqualTo("24.2"); + }); + } + + @Test + public void testUpdateDeduplicationInterval_checkAggregationNotExecutedUntilDeduplicationInterval() throws Exception { + postTelemetry(device1.getId(), "{\"temperature\":24.2}"); + postTelemetry(device2.getId(), "{\"temperature\":19.6}"); + CalculatedField cf = createAvgTemperatureCF(asset.getId()); + + await().alias("create avg temp cf and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24")); + }); + + var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); + configuration.setDeduplicationIntervalInSec(2 * deduplicationInterval); + saveCalculatedField(cf); + + await().alias("update deduplication interval and perform aggregation").atMost(deduplicationInterval / 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24")); + }); + + postTelemetry(device2.getId(), "{\"temperature\":32.1}"); + + await().alias("update telemetry and perform aggregation").atMost(2 * deduplicationInterval + 10, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of("avgTemperature", "28")); + }); + } + + private void checkInitialCalculation() { + await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(this::checkInitialCalculationValues); + } + + private void checkInitialCalculationValues() throws Exception { + ObjectNode occupancy = getLatestTelemetry(asset.getId(), "freeSpaces", "occupiedSpaces", "totalSpaces"); + assertThat(occupancy).isNotNull(); + assertThat(occupancy.get("freeSpaces").get(0).get("value").asText()).isEqualTo("1"); + assertThat(occupancy.get("occupiedSpaces").get(0).get("value").asText()).isEqualTo("1"); + assertThat(occupancy.get("totalSpaces").get(0).get("value").asText()).isEqualTo("2"); + } + + private CalculatedField createAvgTemperatureCF(EntityId entityId) { + Map arguments = new HashMap<>(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + argument.setDefaultValue("20"); + arguments.put("temp", argument); + + Map aggMetrics = new HashMap<>(); + + AggMetric avgMetric = new AggMetric(); + avgMetric.setFunction(AggFunction.AVG); + avgMetric.setFilter("return temp >= 20;"); + avgMetric.setInput(new AggKeyInput("temp")); + aggMetrics.put("avgTemperature", avgMetric); + + Output output = new Output(); + output.setType(OutputType.TIME_SERIES); + output.setDecimalsByDefault(0); + + return createAggCf("Average temperature", entityId, + new RelationPathLevel(EntitySearchDirection.FROM, "Contains"), + arguments, + aggMetrics, + output); + } + + private CalculatedField createOccupancyCF(EntityId entityId) { + Map arguments = new HashMap<>(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("occupied", ArgumentType.TS_LATEST, null)); + argument.setDefaultValue("false"); + arguments.put("oc", argument); + + Map aggMetrics = new HashMap<>(); + + AggMetric freeSpaces = new AggMetric(); + freeSpaces.setFunction(AggFunction.COUNT); + freeSpaces.setFilter("return oc == false;"); + freeSpaces.setInput(new AggKeyInput("oc")); + aggMetrics.put("freeSpaces", freeSpaces); + + AggMetric occupiedSpaces = new AggMetric(); + occupiedSpaces.setFunction(AggFunction.COUNT); + occupiedSpaces.setFilter("return oc == true;"); + occupiedSpaces.setInput(new AggKeyInput("oc")); + aggMetrics.put("occupiedSpaces", occupiedSpaces); + + AggMetric totalSpaces = new AggMetric(); + totalSpaces.setFunction(AggFunction.COUNT); + totalSpaces.setInput(new AggFunctionInput("return 1;")); + aggMetrics.put("totalSpaces", totalSpaces); + + Output output = new Output(); + output.setType(OutputType.TIME_SERIES); + output.setDecimalsByDefault(0); + + return createAggCf("Occupied spaces", entityId, + new RelationPathLevel(EntitySearchDirection.FROM, "Contains"), + arguments, + aggMetrics, + output); + } + + private CalculatedField createOccupancyCFWithAttr(EntityId entityId) { + Map arguments = new HashMap<>(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("occupied", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + argument.setDefaultValue("false"); + arguments.put("oc", argument); + + Map aggMetrics = new HashMap<>(); + + AggMetric freeSpaces = new AggMetric(); + freeSpaces.setFunction(AggFunction.COUNT); + freeSpaces.setFilter("return oc == false;"); + freeSpaces.setInput(new AggKeyInput("oc")); + aggMetrics.put("freeSpaces", freeSpaces); + + AggMetric occupiedSpaces = new AggMetric(); + occupiedSpaces.setFunction(AggFunction.COUNT); + occupiedSpaces.setFilter("return oc == true;"); + occupiedSpaces.setInput(new AggKeyInput("oc")); + aggMetrics.put("occupiedSpaces", occupiedSpaces); + + AggMetric totalSpaces = new AggMetric(); + totalSpaces.setFunction(AggFunction.COUNT); + totalSpaces.setInput(new AggFunctionInput("return 1;")); + aggMetrics.put("totalSpaces", totalSpaces); + + Output output = new Output(); + output.setType(OutputType.TIME_SERIES); + output.setDecimalsByDefault(0); + + return createAggCf("Occupied spaces", entityId, + new RelationPathLevel(EntitySearchDirection.FROM, "Contains"), + arguments, + aggMetrics, + output); + } + + private CalculatedField createAggCf(String name, + EntityId entityId, + RelationPathLevel relation, + Map inputs, + Map metrics, + Output output) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setName(name); + calculatedField.setEntityId(entityId); + calculatedField.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + + RelatedEntitiesAggregationCalculatedFieldConfiguration configuration = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); + configuration.setRelation(relation); + configuration.setArguments(inputs); + configuration.setDeduplicationIntervalInSec(deduplicationInterval); + configuration.setScheduledUpdateInterval(10); + configuration.setMetrics(metrics); + configuration.setOutput(output); + + calculatedField.setConfiguration(configuration); + calculatedField.setDebugSettings(DebugSettings.all()); + return saveCalculatedField(calculatedField); + } + + private Device createDevice(String name, DeviceProfileId deviceProfileId, String accessToken) { + Device device = new Device(); + device.setName(name); + device.setDeviceProfileId(deviceProfileId); + DeviceData deviceData = new DeviceData(); + deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); + deviceData.setConfiguration(new DefaultDeviceConfiguration()); + device.setDeviceData(deviceData); + return doPost("/api/device?accessToken=" + accessToken, device, Device.class); + } + + private Asset createAsset(String name, AssetProfileId assetProfileId) { + Asset asset = new Asset(); + asset.setName(name); + asset.setAssetProfileId(assetProfileId); + return doPost("/api/asset", asset, Asset.class); + } + + private void verifyTelemetry(EntityId entityId, Map expectedResults) throws Exception { + ObjectNode result = getLatestTelemetry(entityId, expectedResults.keySet().toArray(new String[0])); + assertThat(result).isNotNull(); + expectedResults.forEach((key, value) -> assertThat(result.get(key).get(0).get("value").asText()).isEqualTo(value)); + } + + private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { + return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); + } + + private ArrayNode getServerAttributes(EntityId entityId, String... keys) throws Exception { + return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/attributes/SERVER_SCOPE?keys=" + String.join(",", keys), ArrayNode.class); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java index 01c24ad493..d4eec0d648 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java @@ -128,11 +128,17 @@ public abstract class AbstractNotifyEntityTest extends AbstractWebTest { protected void testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(HasName entity, EntityId entityId, EntityId originatorId, TenantId tenantId, CustomerId customerId, UserId userId, String userName, ActionType actionType, ActionType actionTypeEdge, Object... additionalInfo) { + testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(tenantId, entity, entityId, originatorId, tenantId, customerId, userId, userName, actionType, actionTypeEdge, additionalInfo); + } + + protected void testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(TenantId entityTenantId, HasName entity, EntityId entityId, EntityId originatorId, + TenantId authTenantId, CustomerId customerId, UserId userId, String userName, + ActionType actionType, ActionType actionTypeEdge, Object... additionalInfo) { int cntTime = 1; - testNotificationMsgToEdgeServiceTime(entityId, tenantId, actionTypeEdge, cntTime); - testLogEntityActionEntityEqClass(entity, originatorId, tenantId, customerId, userId, userName, actionType, cntTime, additionalInfo); + testNotificationMsgToEdgeServiceTime(entityId, entityTenantId, actionTypeEdge, cntTime); + testLogEntityActionEntityEqClass(entity, originatorId, authTenantId, customerId, userId, userName, actionType, cntTime, additionalInfo); ArgumentMatcher matcherOriginatorId = argument -> argument.equals(originatorId); - testPushMsgToRuleEngineTime(matcherOriginatorId, tenantId, entity, cntTime); + testPushMsgToRuleEngineTime(matcherOriginatorId, authTenantId, entity, cntTime); Mockito.reset(tbClusterService, auditLogService); } @@ -159,17 +165,26 @@ public abstract class AbstractNotifyEntityTest extends AbstractWebTest { TenantId tenantId, CustomerId customerId, UserId userId, String userName, ActionType actionType, int cntTime, int cntTimeEdge, int cntTimeRuleEngine, Object... additionalInfo) { + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(tenantId, entity, originator, tenantId, customerId, userId, userName, actionType, + cntTime, cntTimeEdge, cntTimeRuleEngine, additionalInfo); + } + + protected void testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(TenantId entityTenantId, HasName entity, HasName originator, + TenantId authTenantId, CustomerId customerId, UserId userId, String userName, + ActionType actionType, + int cntTime, int cntTimeEdge, int cntTimeRuleEngine, Object... additionalInfo) { EntityId originatorId = createEntityId_NULL_UUID(originator); - testSendNotificationMsgToEdgeServiceTimeEntityEqAny(tenantId, actionType, cntTimeEdge); + testSendNotificationMsgToEdgeServiceTimeEntityEqAny(entityTenantId, actionType, cntTimeEdge); ArgumentMatcher matcherEntityClassEquals = argument -> argument.getClass().equals(entity.getClass()); ArgumentMatcher matcherOriginatorId = argument -> argument.getClass().equals(originatorId.getClass()); ArgumentMatcher matcherCustomerId = customerId == null ? argument -> argument.getClass().equals(CustomerId.class) : argument -> argument.equals(customerId); ArgumentMatcher matcherUserId = userId == null ? argument -> argument.getClass().equals(UserId.class) : argument -> argument.equals(userId); - testLogEntityActionAdditionalInfo(matcherEntityClassEquals, matcherOriginatorId, tenantId, matcherCustomerId, matcherUserId, userName, actionType, cntTime, + testLogEntityActionAdditionalInfo(matcherEntityClassEquals, matcherOriginatorId, authTenantId, matcherCustomerId, matcherUserId, userName, actionType, cntTime, extractMatcherAdditionalInfoClass(additionalInfo)); - testPushMsgToRuleEngineTime(matcherOriginatorId, tenantId, entity, cntTimeRuleEngine); + testPushMsgToRuleEngineTime(matcherOriginatorId, authTenantId, entity, cntTimeRuleEngine); + } protected void testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAnyAdditionalInfoAny(HasName entity, HasName originator, diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java index cf9d2feb23..89b2681015 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java @@ -15,19 +15,13 @@ */ package org.thingsboard.server.controller; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.TestPropertySource; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.EventInfo; -import org.thingsboard.server.common.data.event.EventType; -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.data.page.PageData; -import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.dao.rule.RuleChainService; @@ -61,18 +55,6 @@ public abstract class AbstractRuleEngineControllerTest extends AbstractControlle return doGet("/api/ruleChain/metadata/" + ruleChainId.getId().toString(), RuleChainMetaData.class); } - protected PageData getDebugEvents(TenantId tenantId, EntityId entityId, int limit) throws Exception { - return getEvents(tenantId, entityId, EventType.DEBUG_RULE_NODE.getOldName(), limit); - } - - protected PageData getEvents(TenantId tenantId, EntityId entityId, String eventType, int limit) throws Exception { - TimePageLink pageLink = new TimePageLink(limit); - return doGetTypedWithTimePageLink("/api/events/{entityType}/{entityId}/{eventType}?tenantId={tenantId}&", - new TypeReference>() { - }, pageLink, entityId.getEntityType(), entityId.getId(), eventType, tenantId.getId()); - } - - protected JsonNode getMetadata(EventInfo outEvent) { String metaDataStr = outEvent.getBody().get("metadata").asText(); try { diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index fd01581e36..76434b5766 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -77,11 +77,14 @@ import org.thingsboard.server.actors.device.DeviceActorMessageProcessor; import org.thingsboard.server.actors.device.SessionInfo; import org.thingsboard.server.actors.device.ToDeviceRpcRequestMetadata; import org.thingsboard.server.actors.service.DefaultActorService; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.EventInfo; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TbResourceInfo; @@ -89,6 +92,8 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration; import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; import org.thingsboard.server.common.data.device.data.DeviceData; @@ -101,6 +106,7 @@ import org.thingsboard.server.common.data.device.profile.MqttTopics; import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadConfiguration; import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration; import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.event.EventType; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.CustomerId; @@ -142,6 +148,7 @@ import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.common.data.security.model.JwtPair; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.common.msg.session.FeatureType; @@ -155,6 +162,7 @@ import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.queue.memory.InMemoryStorage; import org.thingsboard.server.service.cf.CfRocksDb; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileService; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRequest; import org.thingsboard.server.service.security.auth.rest.LoginRequest; @@ -202,13 +210,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected static final String TEST_DIFFERENT_TENANT_NAME = "TEST DIFFERENT TENANT"; protected static final String SYS_ADMIN_EMAIL = "sysadmin@thingsboard.org"; - private static final String SYS_ADMIN_PASSWORD = "sysadmin"; + protected static final String SYS_ADMIN_PASSWORD = "sysadmin"; protected static final String TENANT_ADMIN_EMAIL = "testtenant@thingsboard.org"; protected static final String TENANT_ADMIN_PASSWORD = "tenant"; protected static final String DIFFERENT_TENANT_ADMIN_EMAIL = "testdifftenant@thingsboard.org"; - private static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant"; + protected static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant"; protected static final String CUSTOMER_USER_EMAIL = "testcustomer@thingsboard.org"; private static final String CUSTOMER_USER_PASSWORD = "customer"; @@ -601,8 +609,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { Assert.assertNotNull(tokenInfo); Assert.assertTrue(tokenInfo.has("token")); Assert.assertTrue(tokenInfo.has("refreshToken")); - String token = tokenInfo.get("token").asText(); - String refreshToken = tokenInfo.get("refreshToken").asText(); + validateAndSetJwtToken(JacksonUtil.treeToValue(tokenInfo, JwtPair.class), username); + } + + protected void validateAndSetJwtToken(JwtPair jwtPair, String username) { + Assert.assertNotNull(jwtPair); + String token = jwtPair.getToken(); + String refreshToken = jwtPair.getRefreshToken(); validateJwtToken(token, username); validateJwtToken(refreshToken, username); this.token = token; @@ -673,9 +686,14 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { } protected Device createDevice(String name, String accessToken) throws Exception { + return createDevice(name, "default", null, accessToken); + } + + protected Device createDevice(String name, String type, String label, String accessToken) throws Exception { Device device = new Device(); device.setName(name); - device.setType("default"); + device.setType(type); + device.setLabel(label); DeviceData deviceData = new DeviceData(); deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); deviceData.setConfiguration(new DefaultDeviceConfiguration()); @@ -731,6 +749,12 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return doPost("/api/device-with-credentials", request, Device.class); } + protected ResultActions doGetAsync(String urlTemplate, MultiValueMap params) throws Exception { + MockHttpServletRequestBuilder getRequest = get(urlTemplate).params(params); + setJwtToken(getRequest); + return mockMvc.perform(asyncDispatch(mockMvc.perform(getRequest).andExpect(request().asyncStarted()).andReturn())); + } + protected ResultActions doGet(String urlTemplate, Object... urlVariables) throws Exception { MockHttpServletRequestBuilder getRequest = get(urlTemplate, urlVariables); setJwtToken(getRequest); @@ -930,6 +954,15 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return mockMvc.perform(deleteRequest); } + protected ResultActions doDeleteAsync(String urlTemplate, MultiValueMap params) throws Exception { + MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate) + .params(params); + setJwtToken(deleteRequest); + MvcResult result = mockMvc.perform(deleteRequest).andReturn(); + result.getAsyncResult(DEFAULT_TIMEOUT); + return mockMvc.perform(asyncDispatch(result)); + } + protected ResultActions doDeleteAsync(String urlTemplate, Long timeout, String... params) throws Exception { MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate, params); setJwtToken(deleteRequest); @@ -1056,6 +1089,16 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { doPost("/api/relation", relation); } + protected void deleteEntityRelation(EntityRelation entityRelation) throws Exception { + String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + entityRelation.getFrom().getId(), + entityRelation.getFrom().getEntityType(), + entityRelation.getType(), + entityRelation.getTo().getId(), + entityRelation.getTo().getEntityType()); + doDelete(url); + } + protected List findRelationsByTo(EntityId entityId) throws Exception { String url = String.format("/api/relations?toId=%s&toType=%s", entityId.getId(), entityId.getEntityType().name()); MvcResult mvcResult = doGet(url).andReturn(); @@ -1104,14 +1147,15 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { }); } - protected void awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsDirty(EntityId entityId, CalculatedFieldId cfId) { + protected void awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsReadyToRefreshDynamicArguments(EntityId entityId, CalculatedFieldId cfId, int scheduledUpdateInterval) { CalculatedFieldEntityMessageProcessor processor = getCalculatedFieldEntityMessageProcessor(entityId); Map statesMap = (Map) ReflectionTestUtils.getField(processor, "states"); - Awaitility.await("CF state for entity actor marked as dirty").atMost(5, TimeUnit.SECONDS).until(() -> { + Awaitility.await("CF state for entity actor ready to refresh dynamic arguments").atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> { CalculatedFieldState calculatedFieldState = statesMap.get(cfId); - boolean stateDirty = calculatedFieldState != null && calculatedFieldState.isDirty(); - log.warn("entityId {}, cfId {}, state dirty == {}", entityId, cfId, stateDirty); - return stateDirty; + boolean isReady = calculatedFieldState != null && ((GeofencingCalculatedFieldState) calculatedFieldState).getLastDynamicArgumentsRefreshTs() + < System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(scheduledUpdateInterval); + log.warn("entityId {}, cfId {}, state ready to refresh == {}", entityId, cfId, isReady); + return isReady; }); } @@ -1300,7 +1344,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected List findJobs(List types, List entities) throws Exception { return doGetTypedWithPageLink("/api/jobs?types=" + types.stream().map(Enum::name).collect(Collectors.joining(",")) + - "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&", + "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&", new TypeReference>() {}, new PageLink(100, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC))).getData(); } @@ -1312,4 +1356,34 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { doPost("/api/job/" + jobId + "/reprocess").andExpect(status().isOk()); } + protected void postTelemetry(EntityId entityId, String payload) throws Exception { + doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); + } + + protected void postAttributes(EntityId entityId, AttributeScope scope, String payload) throws Exception { + doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + + "/attributes/" + scope, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); + } + + protected CalculatedField saveCalculatedField(CalculatedField calculatedField) { + return doPost("/api/calculatedField", calculatedField, CalculatedField.class); + } + + protected PageData getCalculatedFields(EntityId entityId, CalculatedFieldType type, PageLink pageLink) throws Exception { + return doGetTypedWithPageLink("/api/" + entityId.getEntityType() + "/" + entityId.getId() + "/calculatedFields" + + (type != null ? "?type=" + type.name() + "&" : "?"), new TypeReference<>() {}, pageLink); + } + + protected PageData getDebugEvents(TenantId tenantId, EntityId entityId, int limit) throws Exception { + return getEvents(tenantId, entityId, EventType.DEBUG_RULE_NODE, limit); + } + + protected PageData getEvents(TenantId tenantId, EntityId entityId, EventType eventType, int limit) throws Exception { + TimePageLink pageLink = new TimePageLink(limit); + return doGetTypedWithTimePageLink("/api/events/{entityType}/{entityId}/{eventType}?tenantId={tenantId}&", + new TypeReference>() { + }, pageLink, entityId.getEntityType(), entityId.getId(), eventType, tenantId.getId()); + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java index ae2972b0cc..53648af441 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java @@ -104,7 +104,10 @@ public class AiModelControllerTest extends AbstractControllerTest { var model = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class); var newModelConfig = OpenAiChatModelConfig.builder() - .providerConfig(new OpenAiProviderConfig("test-api-key-updated")) + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL) + .apiKey("test-api-key-updated") + .build()) .modelId("o4-mini") .temperature(0.2) .topP(0.4) @@ -270,7 +273,7 @@ public class AiModelControllerTest extends AbstractControllerTest { .tenantId(tenantId) .name("Test model 1") .configuration(OpenAiChatModelConfig.builder() - .providerConfig(new OpenAiProviderConfig("test-api-key")) + .providerConfig(OpenAiProviderConfig.builder().apiKey("test-api-key").build()) .modelId("o3-pro") .build()) .build(), AiModel.class); @@ -594,7 +597,10 @@ public class AiModelControllerTest extends AbstractControllerTest { private AiModel constructValidOpenAiModel(String name) { var modelConfig = OpenAiChatModelConfig.builder() - .providerConfig(new OpenAiProviderConfig("test-api-key")) + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL) + .apiKey("test-api-key") + .build()) .modelId("gpt-4o") .temperature(0.5) .topP(0.3) diff --git a/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java index 90b5cbeef2..e160fff59e 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java @@ -1080,6 +1080,30 @@ public class AssetControllerTest extends AbstractControllerTest { testEntityDaoWithRelationsTransactionalException(assetDao, savedTenant.getId(), assetId, "/api/asset/" + assetId); } + @Test + public void testSaveAssetWithUniquifyStrategy() throws Exception { + Asset asset = new Asset(); + asset.setName("My unique asset"); + asset.setType("default"); + doPost("/api/asset", asset, Asset.class); + + doPost("/api/asset", asset).andExpect(status().isBadRequest()); + + doPost("/api/asset?nameConflictPolicy=FAIL", asset).andExpect(status().isBadRequest()); + + Asset secondAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY", asset, Asset.class); + assertThat(secondAsset.getName()).startsWith("My unique asset_"); + + Asset thirdAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", asset, Asset.class); + assertThat(thirdAsset.getName()).startsWith("My unique asset-"); + + Asset fourthAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", asset, Asset.class); + assertThat(fourthAsset.getName()).isEqualTo("My unique asset_1"); + + Asset fifthAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", asset, Asset.class); + assertThat(fifthAsset.getName()).isEqualTo("My unique asset_2"); + } + private Asset createAsset(String name) { Asset asset = new Asset(); asset.setName(name); diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java index af43b34558..4ebace6ae7 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -28,16 +28,28 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; -import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; +import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.service.DaoSqlTest; +import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS; @DaoSqlTest public class CalculatedFieldControllerTest extends AbstractControllerTest { @@ -73,7 +85,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { @Test public void testSaveCalculatedField() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - CalculatedField calculatedField = getCalculatedField(testDevice.getId()); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); @@ -84,7 +96,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId()); assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); - assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getCalculatedFieldConfig()); + assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getSimpleCalculatedFieldConfig()); assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); savedCalculatedField.setName("Test CF"); @@ -98,10 +110,76 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { .andExpect(status().isOk()); } + @Test + public void testSaveGeofencingCalculatedField() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.GEOFENCING); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + assertThat(savedCalculatedField).isNotNull(); + assertThat(savedCalculatedField.getId()).isNotNull(); + assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0); + assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId()); + assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId()); + assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); + assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); + assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getGeofencingCalculatedFieldConfig()); + assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); + + savedCalculatedField.setName("Test CF"); + + CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); + + assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName()); + assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); + + doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSavePropagationCalculatedField() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.PROPAGATION); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + assertThat(savedCalculatedField).isNotNull(); + assertThat(savedCalculatedField.getId()).isNotNull(); + assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0); + assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId()); + assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId()); + assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); + assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); + assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getPropagationCalculatedFieldConfig()); + assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); + + savedCalculatedField.setName("Test CF"); + + CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); + + assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName()); + assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); + + doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSavePropagationCalculatedFieldWithNullArguments() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.PROPAGATION, getPropagationCalculatedFieldConfig(null)); + + doPost("/api/calculatedField", calculatedField) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("arguments must not be empty"))); + } + @Test public void testGetCalculatedFieldById() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - CalculatedField calculatedField = getCalculatedField(testDevice.getId()); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); CalculatedField fetchedCalculatedField = doGet("/api/calculatedField/" + savedCalculatedField.getId().getId(), CalculatedField.class); @@ -113,10 +191,22 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { .andExpect(status().isOk()); } + @Test + public void testGetCalculatedFields() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); + calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + assertThat(getCalculatedFields(testDevice.getId(), null, new PageLink(10)).getData()) + .singleElement().isEqualTo(calculatedField); + assertThat(getCalculatedFields(testDevice.getId(), CalculatedFieldType.SIMPLE, new PageLink(10)).getData()) + .singleElement().isEqualTo(calculatedField); + } + @Test public void testDeleteCalculatedField() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - CalculatedField calculatedField = getCalculatedField(testDevice.getId()); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); @@ -127,18 +217,77 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { doGet("/api/calculatedField/" + savedCalculatedField.getId().getId()).andExpect(status().isNotFound()); } - private CalculatedField getCalculatedField(DeviceId deviceId) { + private CalculatedField getSimpleCalculatedField(EntityId entityId) { + return getCalculatedField(entityId, CalculatedFieldType.SIMPLE); + } + + private CalculatedField getCalculatedField(EntityId entityId, CalculatedFieldType cfType) { + return getCalculatedField(entityId, cfType, null); + } + + private CalculatedField getCalculatedField(EntityId entityId, CalculatedFieldType cfType, CalculatedFieldConfiguration customConfiguration) { CalculatedField calculatedField = new CalculatedField(); - calculatedField.setEntityId(deviceId); - calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setEntityId(entityId); + calculatedField.setType(cfType); calculatedField.setName("Test Calculated Field"); calculatedField.setConfigurationVersion(1); - calculatedField.setConfiguration(getCalculatedFieldConfig()); + if (customConfiguration != null) { + calculatedField.setConfiguration(customConfiguration); + } else switch (cfType) { + case SIMPLE -> calculatedField.setConfiguration(getSimpleCalculatedFieldConfig()); + case GEOFENCING -> calculatedField.setConfiguration(getGeofencingCalculatedFieldConfig()); + case PROPAGATION -> calculatedField.setConfiguration(getPropagationCalculatedFieldConfig()); + } calculatedField.setVersion(1L); return calculatedField; } - private CalculatedFieldConfiguration getCalculatedFieldConfig() { + private CalculatedFieldConfiguration getGeofencingCalculatedFieldConfig() { + var config = new GeofencingCalculatedFieldConfiguration(); + + var refDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + refDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "FromSafeArea"))); + + var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + zoneGroupConfiguration.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration); + + Output output = new Output(); + output.setType(OutputType.TIME_SERIES); + + config.setEntityCoordinates(new EntityCoordinates("latitide", "longitude")); + config.setZoneGroups(Map.of("safeArea", zoneGroupConfiguration)); + config.setScheduledUpdateEnabled(false); + config.setOutput(output); + + return config; + } + + private CalculatedFieldConfiguration getPropagationCalculatedFieldConfig() { + Argument arg = new Argument(); + arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + return getPropagationCalculatedFieldConfig(Map.of("t", arg)); + } + + private CalculatedFieldConfiguration getPropagationCalculatedFieldConfig(Map arguments) { + var config = new PropagationCalculatedFieldConfiguration(); + + config.setRelation(new RelationPathLevel(EntitySearchDirection.TO, EntityRelation.CONTAINS_TYPE)); + + config.setApplyExpressionToResolvedArguments(false); + config.setExpression(null); + + Output output = new Output(); + output.setType(OutputType.TIME_SERIES); + config.setOutput(output); + + Argument arg = new Argument(); + arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + config.setArguments(arguments); + + return config; + } + + private CalculatedFieldConfiguration getSimpleCalculatedFieldConfig() { SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); Argument argument = new Argument(); diff --git a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java index f312e49f71..f4e91f993e 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java @@ -33,6 +33,7 @@ import org.springframework.context.annotation.Primary; import org.springframework.test.context.ContextConfiguration; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; @@ -462,6 +463,27 @@ public class CustomerControllerTest extends AbstractControllerTest { testEntityDaoWithRelationsTransactionalException(customerDao, savedTenant.getId(), customerId, "/api/customer/" + customerId); } + @Test + public void testSaveCustomerWithUniquifyStrategy() throws Exception { + Customer customer = new Customer(); + customer.setTitle("My unique customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + doPost("/api/customer?nameConflictPolicy=FAIL", customer).andExpect(status().isBadRequest()); + + Customer secondCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY", customer, Customer.class); + assertThat(secondCustomer.getName()).startsWith("My unique customer_"); + + Customer thirdCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", customer, Customer.class); + assertThat(thirdCustomer.getName()).startsWith("My unique customer-"); + + Customer fourthCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", customer, Customer.class); + assertThat(fourthCustomer.getName()).isEqualTo("My unique customer_1"); + + Customer fifthCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", customer, Customer.class); + assertThat(fifthCustomer.getName()).isEqualTo("My unique customer_2"); + } + private Customer createCustomer(String title) { Customer customer = new Customer(); customer.setTitle(title); diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java index 36cede9e84..78679948ab 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java @@ -38,6 +38,7 @@ import org.springframework.test.context.ContextConfiguration; import org.testcontainers.shaded.org.awaitility.Awaitility; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceInfo; @@ -59,6 +60,7 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceCredentialsId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.relation.EntityRelation; @@ -77,10 +79,15 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.service.gateway_device.GatewayNotificationsService; import org.thingsboard.server.service.state.DeviceStateService; +import org.thingsboard.server.utils.CsvUtils; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; @@ -387,6 +394,48 @@ public class DeviceControllerTest extends AbstractControllerTest { .andExpect(statusReason(containsString("Device can`t be referencing to device profile from different tenant!"))); } + @Test + public void testSaveDeviceWithFirmware() throws Exception { + loginTenantAdmin(); + DeviceProfile profile = createDeviceProfile("Profile to test ota updates"); + profile = doPost("/api/deviceProfile", profile, DeviceProfile.class); + + SaveOtaPackageInfoRequest firmwareInfo = new SaveOtaPackageInfoRequest(); + firmwareInfo.setDeviceProfileId(profile.getId()); + firmwareInfo.setType(FIRMWARE); + String title = "title"; + firmwareInfo.setTitle(title); + String fwVersion = "1.0"; + firmwareInfo.setVersion(fwVersion); + String url = "test.url"; + firmwareInfo.setUrl(url); + firmwareInfo.setUsesUrl(true); + OtaPackageInfo savedFw = doPost("/api/otaPackage", firmwareInfo, OtaPackageInfo.class); + + Device device = new Device(); + device.setName("My ota device"); + device.setDeviceProfileId(profile.getId()); + device.setFirmwareId(savedFw.getId()); + device = doPost("/api/device", device, Device.class); + + //check shared attributes + Device finalDevice = device; + await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> { + List> attributes = doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + finalDevice.getId() + + "/values/attributes/SHARED_SCOPE", new TypeReference>>() { + }); + return findAttrValue("fw_version", attributes).equals(fwVersion) && + findAttrValue("fw_title", attributes).equals(title) && + findAttrValue("fw_url", attributes).equals(url); + }); + } + + private static Object findAttrValue(String key, List> attributes) { + Optional> attr = attributes.stream() + .filter(att -> att.get("key").equals(key)).findFirst(); + return attr.isPresent() ? attr.get().get("value") : ""; + } + @Test public void testSaveDeviceWithFirmwareFromDifferentTenant() throws Exception { loginDifferentTenant(); @@ -1586,6 +1635,57 @@ public class DeviceControllerTest extends AbstractControllerTest { Assert.assertEquals(newAttributeValue, actualAttribute.get("value")); } + @Test + public void testBulkImportDeviceWithJsonAttr() throws Exception { + String deviceName = "some_device"; + String deviceType = "some_type"; + String deviceAttr = "{\"threshold\":45}"; + + List> content = new LinkedList<>(); + content.add(Arrays.asList("NAME", "TYPE", "ATTR")); + content.add(Arrays.asList(deviceName, deviceType, deviceAttr)); + + byte[] bytes = CsvUtils.generateCsv(content); + BulkImportRequest request = new BulkImportRequest(); + request.setFile(new String(bytes, StandardCharsets.UTF_8)); + BulkImportRequest.Mapping mapping = new BulkImportRequest.Mapping(); + BulkImportRequest.ColumnMapping name = new BulkImportRequest.ColumnMapping(); + name.setType(BulkImportColumnType.NAME); + BulkImportRequest.ColumnMapping type = new BulkImportRequest.ColumnMapping(); + type.setType(BulkImportColumnType.TYPE); + BulkImportRequest.ColumnMapping attr = new BulkImportRequest.ColumnMapping(); + attr.setType(BulkImportColumnType.SERVER_ATTRIBUTE); + attr.setKey("attr"); + List columns = new ArrayList<>(); + columns.add(name); + columns.add(type); + columns.add(attr); + + mapping.setColumns(columns); + mapping.setDelimiter(','); + mapping.setUpdate(true); + mapping.setHeader(true); + request.setMapping(mapping); + + BulkImportResult deviceBulkImportResult = doPostWithTypedResponse("/api/device/bulk_import", request, new TypeReference<>() {}); + + Assert.assertEquals(1, deviceBulkImportResult.getCreated().get()); + Assert.assertEquals(0, deviceBulkImportResult.getErrors().get()); + Assert.assertEquals(0, deviceBulkImportResult.getUpdated().get()); + Assert.assertTrue(deviceBulkImportResult.getErrorsList().isEmpty()); + + Device savedDevice = doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class); + + Assert.assertNotNull(savedDevice); + Assert.assertEquals(deviceName, savedDevice.getName()); + Assert.assertEquals(deviceType, savedDevice.getType()); + + Optional retrieved = await().atMost(5, TimeUnit.SECONDS) + .until(() -> attributesService.find(tenantId, savedDevice.getId(), AttributeScope.SERVER_SCOPE, "attr").get(), Optional::isPresent); + assertThat(retrieved.get().getJsonValue().get()).isEqualTo(deviceAttr); + assertThat(retrieved.get().getStrValue()).isNotPresent(); + } + @Test public void testSaveDeviceWithOutdatedVersion() throws Exception { Device device = createDevice("Device v1.0"); @@ -1608,6 +1708,30 @@ public class DeviceControllerTest extends AbstractControllerTest { assertThat(device.getVersion()).isEqualTo(3); } + @Test + public void testSaveDeviceWithUniquifyStrategy() throws Exception { + Device device = new Device(); + device.setName("My unique device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + + doPost("/api/device", device).andExpect(status().isBadRequest()); + + doPost("/api/device?nameConflictPolicy=FAIL", device).andExpect(status().isBadRequest()); + + Device secondDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY", device, Device.class); + assertThat(secondDevice.getName()).startsWith("My unique device_"); + + Device thirdDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", device, Device.class); + assertThat(thirdDevice.getName()).startsWith("My unique device-"); + + Device fourthDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", device, Device.class); + assertThat(fourthDevice.getName()).isEqualTo("My unique device_1"); + + Device fifthDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", device, Device.class); + assertThat(fifthDevice.getName()).isEqualTo("My unique device_2"); + } + private Device createDevice(String name) { Device device = new Device(); device.setName(name); diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index 1f80c1f379..775df230b6 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -77,6 +77,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -431,14 +432,17 @@ public class EntityQueryControllerTest extends AbstractControllerTest { List alarmFields = new ArrayList<>(); alarmFields.add(new EntityKey(EntityKeyType.ALARM_FIELD, "type")); + alarmFields.add(new EntityKey(EntityKeyType.ALARM_FIELD, "originatorDisplayName")); EntityTypeFilter assetTypeFilter = new EntityTypeFilter(); assetTypeFilter.setEntityType(EntityType.ASSET); AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, alarmFields); PageData alarmPageData = findAlarmsByQueryAndCheck(assetAlarmQuery, 10); - List retrievedAlarmTypes = alarmPageData.getData().stream().map(Alarm::getType).toList(); + List retrievedAlarmTypes = alarmPageData.getData().stream().map(AlarmData::getType).toList(); assertThat(retrievedAlarmTypes).containsExactlyInAnyOrderElementsOf(assetAlarmTypes); + List retrievedAlarmDisplayName = alarmPageData.getData().stream().map(AlarmData::getOriginatorDisplayName).toList(); + assertThat(retrievedAlarmDisplayName).containsExactlyInAnyOrderElementsOf(assets.stream().map(Asset::getLabel).toList()); KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1"); List keyFilters = Collections.singletonList(nameFilter); @@ -519,6 +523,67 @@ public class EntityQueryControllerTest extends AbstractControllerTest { Assert.assertEquals(1, filteredAssetAlamData.getTotalElements()); } + @Test + public void testFindAlarmsWithEntityFilterAndLatestValues() throws Exception { + loginTenantAdmin(); + List devices = new ArrayList<>(); + List temps = new ArrayList<>(); + List deviceNames = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Device device = new Device(); + device.setCustomerId(customerId); + device.setName("Device" + i); + device.setType("default"); + device.setLabel("testLabel" + (int) (Math.random() * 1000)); + device = doPost("/api/device", device, Device.class); + devices.add(device); + deviceNames.add(device.getName()); + + int temp = i * 10; + temps.add(String.valueOf(temp)); + JsonNode content = JacksonUtil.toJsonNode("{\"temperature\": " + temp + "}"); + doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + device.getUuidId() + "/timeseries/SERVER_SCOPE", content) + .andExpect(status().isOk()); + Thread.sleep(1); + } + + for (int i = 0; i < devices.size(); i++) { + Alarm alarm = new Alarm(); + alarm.setCustomerId(customerId); + alarm.setOriginator(devices.get(i).getId()); + String type = "device alarm" + i; + alarm.setType(type); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + AlarmDataPageLink pageLink = new AlarmDataPageLink(); + pageLink.setPage(0); + pageLink.setPageSize(100); + pageLink.setSortOrder(new EntityDataSortOrder(new EntityKey(EntityKeyType.ALARM_FIELD, "created_time"))); + + List alarmFields = new ArrayList<>(); + alarmFields.add(new EntityKey(EntityKeyType.ALARM_FIELD, "type")); + + List entityFields = new ArrayList<>(); + entityFields.add(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + + List latestValues = new ArrayList<>(); + latestValues.add(new EntityKey(EntityKeyType.TIME_SERIES, "temperature")); + + EntityTypeFilter deviceTypeFilter = new EntityTypeFilter(); + deviceTypeFilter.setEntityType(EntityType.DEVICE); + AlarmDataQuery deviceAlarmQuery = new AlarmDataQuery(deviceTypeFilter, pageLink, entityFields, latestValues, null, alarmFields); + + PageData alarmPageData = findAlarmsByQueryAndCheck(deviceAlarmQuery, 10); + List retrievedAlarmTemps = alarmPageData.getData().stream().map(alarmData -> alarmData.getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()).toList(); + assertThat(retrievedAlarmTemps).containsExactlyInAnyOrderElementsOf(temps); + + List retrievedDeviceNames = alarmPageData.getData().stream().map(alarmData -> alarmData.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()).toList(); + assertThat(retrievedDeviceNames).containsExactlyInAnyOrderElementsOf(deviceNames); + } + private void testCountAlarmsByQuery(List alarms) throws Exception { AlarmCountQuery countQuery = new AlarmCountQuery(); @@ -1007,6 +1072,119 @@ public class EntityQueryControllerTest extends AbstractControllerTest { countByQueryAndCheck(customerEntitiesQuery, 0); } + @Test + public void testFindDevicesByDisplayName() throws Exception { + loginTenantAdmin(); + int numOfDevices = 3; + + for (int i = 0; i < numOfDevices; i++) { + Device device = new Device(); + String name = "Device" + i; + device.setName(name); + device.setLabel("Device Label " + i); + device.setType("testFindDevicesByDisplayName"); + + Device savedDevice = doPost("/api/device?accessToken=" + name, device, Device.class); + } + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("testFindDevicesByDisplayName")); + filter.setDeviceNameFilter(""); + + KeyFilter displayNameFilter = getEntityFieldEqualFilter("displayName", "Device Label " + 0); + + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName"), EntityDataSortOrder.Direction.ASC + ); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); + List entityFields = List.of(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName")); + + // all devices with ownerName = TEST TENANT + EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), Collections.emptyList()); + checkEntitiesByQuery(query, numOfDevices, (i, entity) -> { + String name = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("name", new TsValue(0, "Invalid")).getValue(); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("Device" + i, name); + Assert.assertEquals("Device Label " + i, displayName); + }); + + // all devices with ownerName = TEST TENANT + EntityDataQuery displayNameFilterQuery = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(displayNameFilter)); + checkEntitiesByQuery(displayNameFilterQuery, 1, (i, entity) -> { + String name = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("name", new TsValue(0, "Invalid")).getValue(); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("Device" + i, name); + Assert.assertEquals("Device Label " + i, displayName); + }); + } + + @Test + public void testFindUsersByDisplayName() throws Exception { + loginTenantAdmin(); + + User userA = new User(); + userA.setAuthority(Authority.TENANT_ADMIN); + userA.setFirstName("John"); + userA.setLastName("Doe"); + userA.setEmail("john.doe@tb.org"); + userA = doPost("/api/user", userA, User.class); + var aId = userA.getId(); + + User userB = new User(); + userB.setAuthority(Authority.TENANT_ADMIN); + userB.setFirstName("John"); + userB.setEmail("john@tb.org"); + userB = doPost("/api/user", userB, User.class); + var bId = userB.getId(); + + User userC = new User(); + userC.setAuthority(Authority.TENANT_ADMIN); + userC.setLastName("Doe"); + userC.setEmail("doe@tb.org"); + userC = doPost("/api/user", userC, User.class); + var cId = userC.getId(); + + User userD = new User(); + userD.setAuthority(Authority.TENANT_ADMIN); + userD.setEmail("noname@tb.org"); + userD = doPost("/api/user", userD, User.class); + var dId = userD.getId(); + + EntityTypeFilter filter = new EntityTypeFilter(); + filter.setEntityType(EntityType.USER); + + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName"), EntityDataSortOrder.Direction.ASC + ); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); + List entityFields = List.of(new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName")); + + EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "John Doe"))); + checkEntitiesByQuery(query, 1, (i, entity) -> { + Assert.assertEquals(aId, entity.getEntityId()); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("John Doe", displayName); + }); + query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "John"))); + checkEntitiesByQuery(query, 1, (i, entity) -> { + Assert.assertEquals(bId, entity.getEntityId()); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("John", displayName); + }); + query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "Doe"))); + checkEntitiesByQuery(query, 1, (i, entity) -> { + Assert.assertEquals(cId, entity.getEntityId()); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("Doe", displayName); + }); + query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "noname@tb.org"))); + checkEntitiesByQuery(query, 1, (i, entity) -> { + Assert.assertEquals(dId, entity.getEntityId()); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("noname@tb.org", displayName); + }); + } + @Test public void testFindDevicesByOwnerNameAndOwnerType() throws Exception { loginTenantAdmin(); @@ -1044,19 +1222,30 @@ public class EntityQueryControllerTest extends AbstractControllerTest { // all devices with ownerName = TEST TENANT EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, tenantOwnerNameFilter)); - checkEntitiesByQuery(query, numOfDevices, TEST_TENANT_NAME, "TENANT"); + BiConsumer checkFunction = (i, entity) -> { + String name = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("name", new TsValue(0, "Invalid")).getValue(); + String ownerName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("ownerName", new TsValue(0, "Invalid")).getValue(); + String ownerType = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("ownerType", new TsValue(0, "Invalid")).getValue(); + String alarmActiveTime = entity.getLatest().get(EntityKeyType.ATTRIBUTE).getOrDefault("alarmActiveTime", new TsValue(0, "-1")).getValue(); + + Assert.assertEquals("Device" + i, name); + Assert.assertEquals(TEST_TENANT_NAME, ownerName); + Assert.assertEquals("TENANT", ownerType); + Assert.assertEquals("1" + i, alarmActiveTime); + }; + checkEntitiesByQuery(query, numOfDevices, checkFunction); // all devices with wrong ownerName EntityDataQuery wrongTenantNameQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, wrongOwnerNameFilter)); - checkEntitiesByQuery(wrongTenantNameQuery, 0, null, null); + checkEntitiesByQuery(wrongTenantNameQuery, 0, null); // all devices with owner type = TENANT EntityDataQuery tenantEntitiesQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, tenantOwnerTypeFilter)); - checkEntitiesByQuery(tenantEntitiesQuery, numOfDevices, TEST_TENANT_NAME, "TENANT"); + checkEntitiesByQuery(tenantEntitiesQuery, numOfDevices, checkFunction); // all devices with owner type = CUSTOMER EntityDataQuery customerEntitiesQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, customerOwnerTypeFilter)); - checkEntitiesByQuery(customerEntitiesQuery, 0, null, null); + checkEntitiesByQuery(customerEntitiesQuery, 0, null); } @Test @@ -1102,6 +1291,28 @@ public class EntityQueryControllerTest extends AbstractControllerTest { findByQueryAndCheck(query, 0); } + private void checkEntitiesByQuery(EntityDataQuery query, int expectedNumOfDevices, BiConsumer checkFunction) throws Exception { + await() + .alias("data by query") + .atMost(30, TimeUnit.SECONDS) + .until(() -> { + var data = findByQuery(query); + var loadedEntities = new ArrayList<>(data.getData()); + return loadedEntities.size() == expectedNumOfDevices; + }); + if (expectedNumOfDevices == 0) { + return; + } + var data = findByQuery(query); + var loadedEntities = new ArrayList<>(data.getData()); + + Assert.assertEquals(expectedNumOfDevices, loadedEntities.size()); + + for (int i = 0; i < expectedNumOfDevices; i++) { + checkFunction.accept(i, loadedEntities.get(i)); + } + } + private void checkEntitiesByQuery(EntityDataQuery query, int expectedNumOfDevices, String expectedOwnerName, String expectedOwnerType) throws Exception { await() .alias("data by query") diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java index 10b49dfe1d..167048a969 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java @@ -853,4 +853,29 @@ public class EntityViewControllerTest extends AbstractControllerTest { EntityViewId entityViewId = getNewSavedEntityView("EntityView for Test WithRelations Transactional Exception").getId(); testEntityDaoWithRelationsTransactionalException(entityViewDao, tenantId, entityViewId, "/api/entityView/" + entityViewId); } + + @Test + public void testSaveEntityViewWithUniquifyStrategy() throws Exception { + EntityView view = new EntityView(); + view.setEntityId(testDevice.getId()); + view.setTenantId(tenantId); + view.setType("default"); + view.setName("My unique view"); + + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + + doPost("/api/entityView?nameConflictPolicy=FAIL", view).andExpect(status().isBadRequest()); + + EntityView secondView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY", view, EntityView.class); + assertThat(secondView.getName()).startsWith("My unique view_"); + + EntityView thirdView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", view, EntityView.class); + assertThat(thirdView.getName()).startsWith("My unique view-"); + + EntityView fourthView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", view, EntityView.class); + assertThat(fourthView.getName()).isEqualTo("My unique view_1"); + + EntityView fifthEntityView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", view, EntityView.class); + assertThat(fifthEntityView.getName()).isEqualTo("My unique view_2"); + } } diff --git a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java index 3b194de7b4..d88aaa2756 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java @@ -31,6 +31,7 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.DashboardInfo; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.StringUtils; @@ -321,18 +322,15 @@ public class TbResourceControllerTest extends AbstractControllerTest { var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references"); Assert.assertNotNull(referenceValues); - var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { + var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { }); Assert.assertNotNull(widgetTypeInfos); Assert.assertFalse(widgetTypeInfos.isEmpty()); Assert.assertEquals(1, widgetTypeInfos.size()); - var dashboardInfo = widgetTypeInfos.get(EntityType.WIDGET_TYPE.name()).get(0); - Assert.assertNotNull(dashboardInfo); - - WidgetTypeInfo foundedWidgetType = doGet("/api/widgetTypeInfo/" + savedWidgetType.getId().getId().toString(), WidgetTypeInfo.class); - Assert.assertNotNull(foundedWidgetType); - Assert.assertEquals(foundedWidgetType, dashboardInfo); + var widgetTypeInfo = widgetTypeInfos.get(EntityType.WIDGET_TYPE.name()).get(0); + Assert.assertNotNull(widgetTypeInfo); + Assert.assertEquals(new EntityInfo(savedWidgetType.getId(), savedWidgetType.getName()), widgetTypeInfo); } @Test @@ -372,7 +370,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { Assert.assertTrue(isSuccess); var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references"); - var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { + var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { }); Assert.assertNull(widgetTypeInfos); } @@ -417,7 +415,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references"); Assert.assertNotNull(referenceValues); - var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { + var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { }); Assert.assertNotNull(dashboardInfos); Assert.assertFalse(dashboardInfos.isEmpty()); @@ -425,10 +423,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { var dashboardInfo = dashboardInfos.get(EntityType.DASHBOARD.name()).get(0); Assert.assertNotNull(dashboardInfo); - - DashboardInfo foundDashboard = doGet("/api/dashboard/info/" + savedDashboard.getId().getId().toString(), DashboardInfo.class); - Assert.assertNotNull(foundDashboard); - Assert.assertEquals(foundDashboard, dashboardInfo); + Assert.assertEquals(new EntityInfo(savedDashboard.getId(), savedDashboard.getName()), dashboardInfo); } @Test @@ -469,7 +464,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { Assert.assertTrue(isSuccess); var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references"); - var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { + var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { }); Assert.assertNull(dashboardInfos); } @@ -948,7 +943,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { private List loadLwm2mResources() throws Exception { - var models = List.of("1", "2", "3", "5", "6", "9", "19", "3303"); + var models = List.of("1", "2", "3-1_2", "5", "6", "9", "19", "3303"); List resources = new ArrayList<>(models.size()); diff --git a/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java index ad3c6d5312..21371cc316 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java @@ -19,7 +19,10 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Assert; import org.junit.Test; import org.springframework.test.context.TestPropertySource; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; @@ -232,6 +235,63 @@ public class TelemetryControllerTest extends AbstractControllerTest { doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", invalidRequestBody2, String.class, status().isBadRequest()); } + @Test + public void testDeleteTelemetryByKeyWithComma() throws Exception { + loginTenantAdmin(); + Device device = createDevice(); + + String tsKey = "key1,key2"; + String testBody = JacksonUtil.newObjectNode() + .put(tsKey, "value") + .toString(); + doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", testBody, String.class, status().isOk()); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("key", tsKey); + params.add("deleteAllDataForKeys", "true"); + + ObjectNode tsData = readResponse(doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries", params), ObjectNode.class); + assertThat(tsData.get("key1,key2").get(0).get("value").asText()).isEqualTo("value"); + + doDeleteAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/delete", params); + + ObjectNode tsDataAfterDeletion = readResponse(doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries", params), ObjectNode.class); + Assert.assertTrue(tsDataAfterDeletion.get("key1,key2").get(0).get("value").isNull()); + } + + @Test + public void testDeleteTelemetryByKeysWithComma() throws Exception { + loginTenantAdmin(); + Device device = createDevice(); + + String keyWithComma = "key1,key2"; + String testBody = JacksonUtil.newObjectNode() + .put(keyWithComma, "value") + .toString(); + doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", testBody, String.class, status().isOk()); + + String key = "key3"; + String testBody2 = JacksonUtil.newObjectNode() + .put(key, "value") + .toString(); + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("key", keyWithComma); + params.add("key", key); + params.add("deleteAllDataForKeys", "true"); + + doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", testBody2, String.class, status().isOk()); + + ObjectNode tsData = readResponse(doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries", params), ObjectNode.class); + assertThat(tsData.get("key1,key2").get(0).get("value").asText()).isEqualTo("value"); + assertThat(tsData.get("key3").get(0).get("value").asText()).isEqualTo("value"); + + doDeleteAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/delete", params); + + ObjectNode tsDataAfterDeletion = readResponse(doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries", params), ObjectNode.class); + Assert.assertTrue(tsDataAfterDeletion.get("key1,key2").get(0).get("value").isNull()); + Assert.assertTrue(tsDataAfterDeletion.get("key3").get(0).get("value").isNull()); + } + private Device createDevice() throws Exception { String testToken = "TEST_TOKEN"; diff --git a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java index ba45be8e28..a33e8ca411 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java @@ -243,6 +243,29 @@ public class TenantControllerTest extends AbstractControllerTest { .andExpect(statusReason(containsString(msgErrorNoFound("Tenant", tenantIdStr)))); } + @Test + public void testDeleteTenantByTenantAdmin() throws Exception { + loginSysAdmin(); + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + Tenant savedTenant = saveTenant(tenant); + + //login as tenant admin + User tenantAdminUser = new User(); + tenantAdminUser.setAuthority(Authority.TENANT_ADMIN); + tenantAdminUser.setTenantId(savedTenant.getId()); + tenantAdminUser.setEmail("tenantToDelete@thingsboard.io"); + + createUserAndLogin(tenantAdminUser, TENANT_ADMIN_PASSWORD); + + String tenantIdStr = savedTenant.getId().getId().toString(); + deleteTenant(savedTenant.getId()); + loginSysAdmin(); + doGet("/api/tenant/" + tenantIdStr) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Tenant", tenantIdStr)))); + } + @Test public void testFindTenants() throws Exception { loginSysAdmin(); diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java index 3611409cb7..af238fa23b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java @@ -30,6 +30,8 @@ import org.springframework.web.util.UriComponentsBuilder; import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.server.common.data.CacheConstants; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.notification.targets.platform.AllUsersFilter; +import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter; import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings; import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFaAccountConfig; @@ -48,6 +50,7 @@ import org.thingsboard.server.service.security.auth.mfa.provider.impl.TotpTwoFaP import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -85,7 +88,6 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { twoFaConfigManager.deletePlatformTwoFaSettings(tenantId); } - @Test public void testSavePlatformTwoFaSettings() throws Exception { loginSysAdmin(); @@ -102,15 +104,32 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { twoFaSettings.setVerificationCodeCheckRateLimit("3:900"); twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); twoFaSettings.setTotalAllowedTimeForVerification(3600); + twoFaSettings.setEnforceTwoFa(true); + twoFaSettings.setEnforcedUsersFilter(new AllUsersFilter()); - doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk()); + saveTwoFaSettings(twoFaSettings); - PlatformTwoFaSettings savedTwoFaSettings = readResponse(doGet("/api/2fa/settings").andExpect(status().isOk()), PlatformTwoFaSettings.class); + PlatformTwoFaSettings savedTwoFaSettings = findTwoFaSettings(); assertThat(savedTwoFaSettings.getProviders()).hasSize(2); assertThat(savedTwoFaSettings.getProviders()).contains(totpTwoFaProviderConfig, smsTwoFaProviderConfig); } + @Test + public void testSavePlatformTwoFaSettingsWithEnforceTwoFaWithoutProviders() throws Exception { + loginSysAdmin(); + + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); + twoFaSettings.setProviders(List.of()); + twoFaSettings.setMinVerificationCodeSendPeriod(5); + twoFaSettings.setVerificationCodeCheckRateLimit("3:900"); + twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); + twoFaSettings.setTotalAllowedTimeForVerification(3600); + twoFaSettings.setEnforceTwoFa(true); + + doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isBadRequest()); + } + @Test public void testSavePlatformTwoFaSettings_validationError() throws Exception { loginSysAdmin(); @@ -157,17 +176,6 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { assertThat(errorResponse).containsIgnoringCase("verificationCodeLifetime is required"); } - private String savePlatformTwoFaSettingsAndGetError(TwoFaProviderConfig invalidTwoFaProviderConfig) throws Exception { - PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); - twoFaSettings.setProviders(Collections.singletonList(invalidTwoFaProviderConfig)); - twoFaSettings.setMinVerificationCodeSendPeriod(5); - twoFaSettings.setTotalAllowedTimeForVerification(100); - - return getErrorMessage(doPost("/api/2fa/settings", twoFaSettings) - .andExpect(status().isBadRequest())); - } - - @Test public void testSaveTwoFaAccountConfig_providerNotConfigured() throws Exception { configureSmsTwoFaProvider("${code}"); @@ -268,24 +276,6 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { assertThat(errorMessage).containsIgnoringCase("verification code is incorrect"); } - private TotpTwoFaAccountConfig generateTotpTwoFaAccountConfig(TotpTwoFaProviderConfig totpTwoFaProviderConfig) throws Exception { - TwoFaAccountConfig generatedTwoFaAccountConfig = readResponse(doPost("/api/2fa/account/config/generate?providerType=TOTP") - .andExpect(status().isOk()), TwoFaAccountConfig.class); - assertThat(generatedTwoFaAccountConfig).isInstanceOf(TotpTwoFaAccountConfig.class); - - assertThat(((TotpTwoFaAccountConfig) generatedTwoFaAccountConfig)).satisfies(accountConfig -> { - UriComponents otpAuthUrl = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build(); - assertThat(otpAuthUrl.getScheme()).isEqualTo("otpauth"); - assertThat(otpAuthUrl.getHost()).isEqualTo("totp"); - assertThat(otpAuthUrl.getQueryParams().getFirst("issuer")).isEqualTo(totpTwoFaProviderConfig.getIssuerName()); - assertThat(otpAuthUrl.getPath()).isEqualTo("/%s:%s", totpTwoFaProviderConfig.getIssuerName(), TENANT_ADMIN_EMAIL); - assertThat(otpAuthUrl.getQueryParams().getFirst("secret")).satisfies(secretKey -> { - assertDoesNotThrow(() -> Base32.decode(secretKey)); - }); - }); - return (TotpTwoFaAccountConfig) generatedTwoFaAccountConfig; - } - @Test public void testGetTwoFaAccountConfig_whenProviderNotConfigured() throws Exception { testVerifyAndSaveTotpTwoFaAccountConfig(); @@ -419,6 +409,56 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { assertThat(accountConfig).isEqualTo(initialSmsTwoFaAccountConfig); } + @Test + public void testIsTwoFaEnabled() throws Exception { + configureSmsTwoFaProvider("${code}"); + SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig(); + accountConfig.setPhoneNumber("+38050505050"); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUser, accountConfig); + + assertThat(twoFactorAuthService.isTwoFaEnabled(tenantId, tenantAdminUser)).isTrue(); + } + + @Test + public void testDeleteTwoFaAccountConfig() throws Exception { + configureSmsTwoFaProvider("${code}"); + loginTenantAdmin(); + SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig(); + accountConfig.setPhoneNumber("+38050505050"); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUser, accountConfig); + + AccountTwoFaSettings accountTwoFaSettings = readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class); + TwoFaAccountConfig savedAccountConfig = accountTwoFaSettings.getConfigs().get(TwoFaProviderType.SMS); + assertThat(savedAccountConfig).isEqualTo(accountConfig); + + PlatformTwoFaSettings twoFaSettings = twoFaConfigManager.getPlatformTwoFaSettings(TenantId.SYS_TENANT_ID, true).get(); + twoFaSettings.setEnforceTwoFa(true); + TenantAdministratorsFilter enforcedUsersFilter = new TenantAdministratorsFilter(); + enforcedUsersFilter.setTenantsIds(Set.of(tenantId.getId())); + twoFaSettings.setEnforcedUsersFilter(enforcedUsersFilter); + twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); + + String errorMessage = getErrorMessage(doDelete("/api/2fa/account/config?providerType=SMS") + .andExpect(status().isBadRequest())); + assertThat(errorMessage).isEqualTo("At least one 2FA provider is required"); + + twoFaSettings.setEnforceTwoFa(false); + twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); + + doDelete("/api/2fa/account/config?providerType=SMS").andExpect(status().isOk()); + + assertThat(readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class).getConfigs()) + .doesNotContainKey(TwoFaProviderType.SMS); + } + + private PlatformTwoFaSettings findTwoFaSettings() throws Exception { + return doGet("/api/2fa/settings", PlatformTwoFaSettings.class); + } + + private void saveTwoFaSettings(PlatformTwoFaSettings twoFaSettings) throws Exception { + doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk()); + } + private TotpTwoFaProviderConfig configureTotpTwoFaProvider() throws Exception { TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); totpTwoFaProviderConfig.setIssuerName("tb"); @@ -441,37 +481,35 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { twoFaSettings.setProviders(Arrays.stream(providerConfigs).collect(Collectors.toList())); twoFaSettings.setMinVerificationCodeSendPeriod(5); twoFaSettings.setTotalAllowedTimeForVerification(100); - doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk()); + saveTwoFaSettings(twoFaSettings); } - @Test - public void testIsTwoFaEnabled() throws Exception { - configureSmsTwoFaProvider("${code}"); - SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig(); - accountConfig.setPhoneNumber("+38050505050"); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig); + private TotpTwoFaAccountConfig generateTotpTwoFaAccountConfig(TotpTwoFaProviderConfig totpTwoFaProviderConfig) throws Exception { + TwoFaAccountConfig generatedTwoFaAccountConfig = readResponse(doPost("/api/2fa/account/config/generate?providerType=TOTP") + .andExpect(status().isOk()), TwoFaAccountConfig.class); + assertThat(generatedTwoFaAccountConfig).isInstanceOf(TotpTwoFaAccountConfig.class); - assertThat(twoFactorAuthService.isTwoFaEnabled(tenantId, tenantAdminUserId)).isTrue(); + assertThat(((TotpTwoFaAccountConfig) generatedTwoFaAccountConfig)).satisfies(accountConfig -> { + UriComponents otpAuthUrl = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build(); + assertThat(otpAuthUrl.getScheme()).isEqualTo("otpauth"); + assertThat(otpAuthUrl.getHost()).isEqualTo("totp"); + assertThat(otpAuthUrl.getQueryParams().getFirst("issuer")).isEqualTo(totpTwoFaProviderConfig.getIssuerName()); + assertThat(otpAuthUrl.getPath()).isEqualTo("/%s:%s", totpTwoFaProviderConfig.getIssuerName(), TENANT_ADMIN_EMAIL); + assertThat(otpAuthUrl.getQueryParams().getFirst("secret")).satisfies(secretKey -> { + assertDoesNotThrow(() -> Base32.decode(secretKey)); + }); + }); + return (TotpTwoFaAccountConfig) generatedTwoFaAccountConfig; } - @Test - public void testDeleteTwoFaAccountConfig() throws Exception { - configureSmsTwoFaProvider("${code}"); - SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig(); - accountConfig.setPhoneNumber("+38050505050"); - - loginTenantAdmin(); - - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig); - - AccountTwoFaSettings accountTwoFaSettings = readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class); - TwoFaAccountConfig savedAccountConfig = accountTwoFaSettings.getConfigs().get(TwoFaProviderType.SMS); - assertThat(savedAccountConfig).isEqualTo(accountConfig); - - doDelete("/api/2fa/account/config?providerType=SMS").andExpect(status().isOk()); + private String savePlatformTwoFaSettingsAndGetError(TwoFaProviderConfig invalidTwoFaProviderConfig) throws Exception { + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); + twoFaSettings.setProviders(Collections.singletonList(invalidTwoFaProviderConfig)); + twoFaSettings.setMinVerificationCodeSendPeriod(5); + twoFaSettings.setTotalAllowedTimeForVerification(100); - assertThat(readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class).getConfigs()) - .doesNotContainKey(TwoFaProviderType.SMS); + return getErrorMessage(doPost("/api/2fa/settings", twoFaSettings) + .andExpect(status().isBadRequest())); } } 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 10d06150dc..daa213dc6c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -25,6 +25,7 @@ import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.web.util.UriComponentsBuilder; import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; @@ -33,6 +34,8 @@ import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.audit.AuditLog; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.notification.targets.platform.AllUsersFilter; +import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.common.data.page.TimePageLink; @@ -58,6 +61,7 @@ import java.time.Duration; import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -116,7 +120,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { public void testTwoFa_totp() throws Exception { TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); doPost("/api/auth/2fa/verification/send?providerType=TOTP") .andExpect(status().isOk()); @@ -136,7 +140,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { public void testTwoFa_sms() throws Exception { configureSmsTwoFa(); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); doPost("/api/auth/2fa/verification/send?providerType=SMS") .andExpect(status().isOk()); @@ -160,7 +164,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { twoFaSettings.setTotalAllowedTimeForVerification(65); }); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); await("expiration of the pre-verification token") .atLeast(Duration.ofSeconds(30).plusMillis(500)) @@ -177,7 +181,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); }); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); Stream.generate(() -> StringUtils.randomNumeric(6)) .limit(9) @@ -206,7 +210,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { twoFaSettings.setMinVerificationCodeSendPeriod(10); }); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); doPost("/api/auth/2fa/verification/send?providerType=TOTP") .andExpect(status().isOk()); @@ -230,7 +234,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { twoFaSettings.setVerificationCodeCheckRateLimit("3:10"); }); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); for (int i = 0; i < 3; i++) { String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect") @@ -258,7 +262,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { @Test public void testCheckVerificationCode_invalidVerificationCode() throws Exception { configureTotpTwoFa(); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); for (String invalidVerificationCode : new String[]{"1234567", "ab1212", "12311 ", "oewkriwejqf"}) { String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=" + invalidVerificationCode) @@ -273,7 +277,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { smsTwoFaProviderConfig.setVerificationCodeLifetime(10); }); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); doPost("/api/auth/2fa/verification/send?providerType=SMS").andExpect(status().isOk()); @@ -296,7 +300,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { public void testTwoFa_logLoginAction() throws Exception { TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); await("async audit log saving").during(1, TimeUnit.SECONDS); doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect") @@ -334,7 +338,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { @Test public void testAuthWithoutTwoFaAccountConfig() throws ThingsboardException { configureTotpTwoFa(); - twoFaConfigManager.deleteTwoFaAccountConfig(tenantId, user.getId(), TwoFaProviderType.TOTP); + twoFaConfigManager.deleteTwoFaAccountConfig(tenantId, user, TwoFaProviderType.TOTP); assertDoesNotThrow(() -> { login(username, password); @@ -368,17 +372,17 @@ public class TwoFactorAuthTest extends AbstractControllerTest { TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(twoFaUser, TwoFaProviderType.TOTP); totpTwoFaAccountConfig.setUseByDefault(true); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), totpTwoFaAccountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, totpTwoFaAccountConfig); SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); smsTwoFaAccountConfig.setPhoneNumber("+38012312322"); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), smsTwoFaAccountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, smsTwoFaAccountConfig); EmailTwoFaAccountConfig emailTwoFaAccountConfig = new EmailTwoFaAccountConfig(); emailTwoFaAccountConfig.setEmail(twoFaUser.getEmail()); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), emailTwoFaAccountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, emailTwoFaAccountConfig); - logInWithPreVerificationToken(twoFaUser.getEmail(), "12345678"); + logInWithMfaToken(twoFaUser.getEmail(), "12345678", Authority.PRE_VERIFICATION_TOKEN); Map providersInfos = readResponse(doGet("/api/auth/2fa/providers").andExpect(status().isOk()), new TypeReference>() {}).stream() .collect(Collectors.toMap(TwoFactorAuthController.TwoFaProviderInfo::getType, v -> v)); @@ -395,13 +399,79 @@ public class TwoFactorAuthTest extends AbstractControllerTest { assertThat(providersInfos.get(TwoFaProviderType.EMAIL).isDefault()).isFalse(); } - private void logInWithPreVerificationToken(String username, String password) throws Exception { + @Test + public void testEnforceTwoFa() throws Exception { + TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); + totpTwoFaProviderConfig.setIssuerName("tb"); + + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); + twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{totpTwoFaProviderConfig}).collect(Collectors.toList())); + twoFaSettings.setMinVerificationCodeSendPeriod(5); + twoFaSettings.setTotalAllowedTimeForVerification(100); + twoFaSettings.setEnforceTwoFa(true); + TenantAdministratorsFilter enforcedUsersFilter = new TenantAdministratorsFilter(); + enforcedUsersFilter.setTenantsIds(Set.of(tenantId.getId())); + twoFaSettings.setEnforcedUsersFilter(enforcedUsersFilter); + twoFaSettings = twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); + + logInWithMfaToken(username, password, Authority.MFA_CONFIGURATION_TOKEN); + + TotpTwoFaAccountConfig totpTwoFaAccountConfig = doPost("/api/2fa/account/config/generate?providerType=" + totpTwoFaProviderConfig.getProviderType(), TotpTwoFaAccountConfig.class); + + String secret = UriComponentsBuilder.fromUriString(totpTwoFaAccountConfig.getAuthUrl()).build() + .getQueryParams().getFirst("secret"); + String verificationCode = new Totp(secret).now(); + readResponse(doPost("/api/2fa/account/config?verificationCode=" + verificationCode, totpTwoFaAccountConfig).andExpect(status().isOk()), JsonNode.class); + + JwtPair tokenPair = readResponse(doPost("/api/auth/2fa/login").andExpect(status().isOk()), JwtPair.class); + assertThat(tokenPair.getToken()).isNotEmpty(); + assertThat(tokenPair.getRefreshToken()).isNotEmpty(); + validateAndSetJwtToken(tokenPair, username); + + doGet("/api/user/" + user.getId()).andExpect(status().isOk()); + + // verifying enforced users filter + createDifferentTenant(); + doGet("/api/user/" + savedDifferentTenantUser.getId()).andExpect(status().isOk()); + } + + @Test + public void testEnforceTwoFa_sysadmin() throws Exception { + TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); + totpTwoFaProviderConfig.setIssuerName("tb"); + + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); + twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{totpTwoFaProviderConfig}).collect(Collectors.toList())); + twoFaSettings.setMinVerificationCodeSendPeriod(5); + twoFaSettings.setTotalAllowedTimeForVerification(100); + twoFaSettings.setEnforceTwoFa(true); + AllUsersFilter enforcedUsersFilter = new AllUsersFilter(); + twoFaSettings.setEnforcedUsersFilter(enforcedUsersFilter); + twoFaSettings = twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); + + logInWithMfaToken(SYS_ADMIN_EMAIL, SYS_ADMIN_PASSWORD, Authority.MFA_CONFIGURATION_TOKEN); + + TotpTwoFaAccountConfig totpTwoFaAccountConfig = doPost("/api/2fa/account/config/generate?providerType=" + totpTwoFaProviderConfig.getProviderType(), TotpTwoFaAccountConfig.class); + String secret = UriComponentsBuilder.fromUriString(totpTwoFaAccountConfig.getAuthUrl()).build() + .getQueryParams().getFirst("secret"); + String verificationCode = new Totp(secret).now(); + readResponse(doPost("/api/2fa/account/config?verificationCode=" + verificationCode, totpTwoFaAccountConfig).andExpect(status().isOk()), JsonNode.class); + + JwtPair tokenPair = readResponse(doPost("/api/auth/2fa/login").andExpect(status().isOk()), JwtPair.class); + assertThat(tokenPair.getToken()).isNotEmpty(); + assertThat(tokenPair.getRefreshToken()).isNotEmpty(); + validateAndSetJwtToken(tokenPair, SYS_ADMIN_EMAIL); + + doGet("/api/user/" + user.getId()).andExpect(status().isOk()); + } + + private void logInWithMfaToken(String username, String password, Authority expectedScope) throws Exception { LoginRequest loginRequest = new LoginRequest(username, password); JwtPair response = readResponse(doPost("/api/auth/login", loginRequest).andExpect(status().isOk()), JwtPair.class); assertThat(response.getToken()).isNotNull(); assertThat(response.getRefreshToken()).isNull(); - assertThat(response.getScope()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN); + assertThat(response.getScope()).isEqualTo(expectedScope); this.token = response.getToken(); } @@ -418,7 +488,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(user, TwoFaProviderType.TOTP); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), totpTwoFaAccountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user, totpTwoFaAccountConfig); return totpTwoFaAccountConfig; } @@ -436,7 +506,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); smsTwoFaAccountConfig.setPhoneNumber("+38050505050"); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), smsTwoFaAccountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user, smsTwoFaAccountConfig); return smsTwoFaAccountConfig; } diff --git a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java index d7aa6f5770..28d43699fd 100644 --- a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java @@ -116,7 +116,7 @@ public class UserControllerTest extends AbstractControllerTest { foundUser.setAdditionalInfo(savedUser.getAdditionalInfo()); Assert.assertEquals(foundUser, savedUser); - testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(foundUser, foundUser, + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(user.getTenantId(), foundUser, foundUser, SYSTEM_TENANT, customerNUULId, null, SYS_ADMIN_EMAIL, ActionType.ADDED, 1, 1, 1); Mockito.reset(tbClusterService, auditLogService); @@ -155,7 +155,7 @@ public class UserControllerTest extends AbstractControllerTest { doDelete("/api/user/" + savedUser.getId().getId().toString()) .andExpect(status().isOk()); - testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(foundUser, foundUser.getId(), foundUser.getId(), + testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(user.getTenantId(), foundUser, foundUser.getId(), foundUser.getId(), SYSTEM_TENANT, customerNUULId, null, SYS_ADMIN_EMAIL, ActionType.DELETED, ActionType.DELETED, SYSTEM_TENANT.getId().toString()); } @@ -284,6 +284,26 @@ public class UserControllerTest extends AbstractControllerTest { ActionType.ADDED, new DataValidationException(msgError)); } + @Test + public void testShouldNotDeleteLastTenantAdmin() throws Exception { + loginSysAdmin(); + + User tenantAdmin2 = new User(); + tenantAdmin2.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin2.setTenantId(tenantId); + tenantAdmin2.setEmail("tenant2@thingsboard.io"); + tenantAdmin2 = doPost("/api/user", tenantAdmin2, User.class); + + // delete second tenant admin - ok + doDelete("/api/user/" + tenantAdmin2.getId().getId().toString()) + .andExpect(status().isOk()); + + // delete last tenant admin - forbidden + doDelete("/api/user/" + tenantAdminUser.getId().getId().toString()) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("At least one tenant administrator must remain!"))); + } + @Test public void testSaveUserWithInvalidEmail() throws Exception { loginSysAdmin(); @@ -394,7 +414,7 @@ public class UserControllerTest extends AbstractControllerTest { User testManyUser = new User(); testManyUser.setTenantId(tenantId); - testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(testManyUser, testManyUser, + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(tenantId, testManyUser, testManyUser, SYSTEM_TENANT, customerNUULId, null, SYS_ADMIN_EMAIL, ActionType.ADDED, cntEntity, cntEntity, cntEntity); @@ -506,7 +526,7 @@ public class UserControllerTest extends AbstractControllerTest { } User testManyUser = new User(); testManyUser.setTenantId(tenantId); - testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(testManyUser, testManyUser, + testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(tenantId, testManyUser, testManyUser, SYSTEM_TENANT, customerNUULId, null, SYS_ADMIN_EMAIL, ActionType.DELETED, cntEntity, NUMBER_OF_USERS, cntEntity, ""); diff --git a/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java new file mode 100644 index 0000000000..30c8448f5b --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java @@ -0,0 +1,196 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.edge; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.google.protobuf.AbstractMessage; +import com.google.protobuf.InvalidProtocolBufferException; +import org.junit.Assert; +import org.junit.Test; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; + +import java.util.Optional; +import java.util.UUID; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class AiModelEdgeTest extends AbstractEdgeTest { + + private static final String DEFAULT_AI_MODEL_NAME = "Edge Test AiModel"; + private static final String UPDATED_AI_MODEL_NAME = "Updated Edge Test AiModel"; + + @Test + public void testAiModel_create_update_delete() throws Exception { + // create AiModel + AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); + + edgeImitator.expectMessageAmount(1); + AiModel savedAiModel = doPost("/api/ai/model", aiModel, AiModel.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + AbstractMessage latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg); + AiModelUpdateMsg aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType()); + Assert.assertEquals(savedAiModel.getUuidId().getMostSignificantBits(), aiModelUpdateMsg.getIdMSB()); + Assert.assertEquals(savedAiModel.getUuidId().getLeastSignificantBits(), aiModelUpdateMsg.getIdLSB()); + AiModel aiModelFromMsg = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true); + Assert.assertNotNull(aiModelFromMsg); + + Assert.assertEquals(DEFAULT_AI_MODEL_NAME, aiModelFromMsg.getName()); + Assert.assertEquals(savedAiModel.getTenantId(), aiModelFromMsg.getTenantId()); + + // update AiModel + edgeImitator.expectMessageAmount(1); + savedAiModel.setName(UPDATED_AI_MODEL_NAME); + savedAiModel = doPost("/api/ai/model", savedAiModel, AiModel.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg); + aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage; + aiModelFromMsg = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true); + Assert.assertNotNull(aiModelFromMsg); + Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType()); + Assert.assertEquals(UPDATED_AI_MODEL_NAME, aiModelFromMsg.getName()); + + // delete AiModel + edgeImitator.expectMessageAmount(1); + doDelete("/api/ai/model/" + savedAiModel.getUuidId()) + .andExpect(status().isOk()); + Assert.assertTrue(edgeImitator.waitForMessages()); + + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg); + aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType()); + Assert.assertEquals(savedAiModel.getUuidId().getMostSignificantBits(), aiModelUpdateMsg.getIdMSB()); + Assert.assertEquals(savedAiModel.getUuidId().getLeastSignificantBits(), aiModelUpdateMsg.getIdLSB()); + } + + @Test + public void testSendAiModelToCloud() throws Exception { + AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); + UUID uuid = Uuids.timeBased(); + UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + + checkAiModelOnCloud(uplinkMsg, uuid, aiModel.getName()); + } + + @Test + public void testUpdateAiModelNameOnCloud() throws Exception { + AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); + UUID uuid = Uuids.timeBased(); + UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + + checkAiModelOnCloud(uplinkMsg, uuid, aiModel.getName()); + + aiModel.setName(UPDATED_AI_MODEL_NAME); + UplinkMsg updatedUplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE); + + checkAiModelOnCloud(updatedUplinkMsg, uuid, aiModel.getName()); + } + + @Test + public void testAiModelToCloudWithNameThatAlreadyExistsOnCloud() throws Exception { + AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); + + edgeImitator.expectMessageAmount(1); + AiModel savedAiModel = doPost("/api/ai/model", aiModel, AiModel.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + UUID uuid = Uuids.timeBased(); + + UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.expectMessageAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsg); + + Assert.assertTrue(edgeImitator.waitForResponses()); + Assert.assertTrue(edgeImitator.waitForMessages()); + + Optional aiModelUpdateMsgOpt = edgeImitator.findMessageByType(AiModelUpdateMsg.class); + Assert.assertTrue(aiModelUpdateMsgOpt.isPresent()); + AiModelUpdateMsg latestAiModelUpdateMsg = aiModelUpdateMsgOpt.get(); + AiModel aiModelFromMsg = JacksonUtil.fromString(latestAiModelUpdateMsg.getEntity(), AiModel.class, true); + Assert.assertNotNull(aiModelFromMsg); + Assert.assertNotEquals(DEFAULT_AI_MODEL_NAME, aiModelFromMsg.getName()); + + Assert.assertNotEquals(savedAiModel.getUuidId(), uuid); + + AiModel aiModelFromCloud = doGet("/api/ai/model/" + uuid, AiModel.class); + Assert.assertNotNull(aiModelFromCloud); + Assert.assertNotEquals(DEFAULT_AI_MODEL_NAME, aiModelFromCloud.getName()); + } + + private AiModel createSimpleAiModel(String name) { + AiModel aiModel = new AiModel(); + aiModel.setTenantId(tenantId); + aiModel.setName(name); + aiModel.setConfiguration(OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig(null, "test-api-key")) + .modelId("gpt-4o") + .temperature(0.5) + .topP(0.3) + .frequencyPenalty(0.1) + .presencePenalty(0.2) + .maxOutputTokens(1000) + .timeoutSeconds(60) + .maxRetries(2) + .build()); + return aiModel; + } + + private UplinkMsg getUplinkMsg(UUID uuid, AiModel aiModel, UpdateMsgType updateMsgType) throws InvalidProtocolBufferException { + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + AiModelUpdateMsg.Builder aiModelUpdateMsgBuilder = AiModelUpdateMsg.newBuilder(); + aiModelUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + aiModelUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + aiModelUpdateMsgBuilder.setEntity(JacksonUtil.toString(aiModel)); + aiModelUpdateMsgBuilder.setMsgType(updateMsgType); + testAutoGeneratedCodeByProtobuf(aiModelUpdateMsgBuilder); + uplinkMsgBuilder.addAiModelUpdateMsg(aiModelUpdateMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + return uplinkMsgBuilder.build(); + } + + private void checkAiModelOnCloud(UplinkMsg uplinkMsg, UUID uuid, String resourceTitle) throws Exception { + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsg); + + Assert.assertTrue(edgeImitator.waitForResponses()); + + UplinkResponseMsg latestResponseMsg = edgeImitator.getLatestResponseMsg(); + Assert.assertTrue(latestResponseMsg.getSuccess()); + + AiModel aiModel = doGet("/api/ai/model/" + uuid, AiModel.class); + Assert.assertNotNull(aiModel); + Assert.assertEquals(resourceTitle, aiModel.getName()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java index 16d10d9b6e..4594435626 100644 --- a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java +++ b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java @@ -29,6 +29,7 @@ import org.thingsboard.edge.rpc.EdgeGrpcClient; import org.thingsboard.edge.rpc.EdgeRpcClient; import org.thingsboard.server.controller.AbstractWebTest; import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; @@ -358,6 +359,11 @@ public class EdgeImitator { result.add(saveDownlinkMsg(calculatedFieldUpdateMsg)); } } + if (downlinkMsg.getAiModelUpdateMsgCount() > 0) { + for (AiModelUpdateMsg aiModelUpdateMsg : downlinkMsg.getAiModelUpdateMsgList()) { + result.add(saveDownlinkMsg(aiModelUpdateMsg)); + } + } if (downlinkMsg.hasEdgeConfiguration()) { result.add(saveDownlinkMsg(downlinkMsg.getEdgeConfiguration())); } diff --git a/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java b/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java index 3e29f212f0..fb99d6ad0e 100644 --- a/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java @@ -118,7 +118,7 @@ public abstract class AbstractRuleEngineLifecycleIntegrationTest extends Abstrac .pollInterval(10, MILLISECONDS) .atMost(TIMEOUT, TimeUnit.SECONDS) .until(() -> { - List debugEvents = getEvents(tenantId, ruleChainFinal.getFirstRuleNodeId(), EventType.LC_EVENT.getOldName(), 1000) + List debugEvents = getEvents(tenantId, ruleChainFinal.getFirstRuleNodeId(), EventType.LC_EVENT, 1000) .getData().stream().filter(e -> { var body = e.getBody(); return body.has("event") && body.get("event").asText().equals("STARTED") 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 20f8aaf881..969f6de9f5 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 @@ -20,9 +20,12 @@ import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.ApiUsageStateValue; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.ApiUsageStateId; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; @@ -33,8 +36,17 @@ import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import java.lang.reflect.Field; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; +import java.util.concurrent.TimeUnit; +import static java.time.ZoneOffset.UTC; +import static java.time.temporal.ChronoField.DAY_OF_MONTH; +import static java.time.temporal.ChronoUnit.MONTHS; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; @DaoSqlTest @@ -48,6 +60,7 @@ public class DefaultTbApiUsageStateServiceTest extends AbstractControllerTest { private TenantId tenantId; private Tenant savedTenant; + private TenantProfile savedTenantProfile; private static final int MAX_ENABLE_VALUE = 5000; private static final long VALUE_WARNING = 4500L; @@ -59,7 +72,7 @@ public class DefaultTbApiUsageStateServiceTest extends AbstractControllerTest { loginSysAdmin(); TenantProfile tenantProfile = createTenantProfile(); - TenantProfile savedTenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); + savedTenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); Assert.assertNotNull(savedTenantProfile); Tenant tenant = new Tenant(); @@ -109,6 +122,41 @@ public class DefaultTbApiUsageStateServiceTest extends AbstractControllerTest { assertEquals(ApiUsageStateValue.DISABLED, apiUsageStateService.findTenantApiUsageState(tenantId).getDbStorageState()); } + @Test + public void checkStartOfNextCycle_setsNextCycleToNextMonth() throws Exception { + ApiUsageState apiUsageState = new ApiUsageState(new ApiUsageStateId(UUID.randomUUID())); + apiUsageState.setDbStorageState(ApiUsageStateValue.ENABLED); + apiUsageState.setAlarmExecState(ApiUsageStateValue.ENABLED); + apiUsageState.setSmsExecState(ApiUsageStateValue.ENABLED); + apiUsageState.setTbelExecState(ApiUsageStateValue.ENABLED); + apiUsageState.setReExecState(ApiUsageStateValue.ENABLED); + apiUsageState.setTransportState(ApiUsageStateValue.ENABLED); + apiUsageState.setEmailExecState(ApiUsageStateValue.ENABLED); + apiUsageState.setJsExecState(ApiUsageStateValue.ENABLED); + apiUsageState.setTenantId(tenantId); + apiUsageState.setEntityId(tenantId); + + long now = System.currentTimeMillis(); + long currentCycleTs = now - TimeUnit.DAYS.toMillis(30); + long nextCycleTs = now - TimeUnit.MINUTES.toMillis(5); // < 1h ago + TenantApiUsageState tenantApiUsageState = new TenantApiUsageState(savedTenantProfile, apiUsageState); + tenantApiUsageState.setCycles(currentCycleTs, nextCycleTs); + Map map = new HashMap<>(); + map.put(tenantId, tenantApiUsageState); + + Field fieldToSet = DefaultTbApiUsageStateService.class.getDeclaredField("myUsageStates"); + fieldToSet.setAccessible(true); + fieldToSet.set(service, map); + + service.checkStartOfNextCycle(); + + long firstOfNextMonth = LocalDate.now() + .with((temporal) -> temporal.with(DAY_OF_MONTH, 1) + .plus(1, MONTHS)) + .atStartOfDay(UTC).toInstant().toEpochMilli(); + assertThat(tenantApiUsageState.getNextCycleTs()).isEqualTo(firstOfNextMonth); + } + private TenantProfile createTenantProfile() { TenantProfile tenantProfile = new TenantProfile(); tenantProfile.setName("Tenant Profile"); diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java index 691a1f7ec4..d144fe4dcc 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java @@ -20,15 +20,17 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; -import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy; @@ -41,12 +43,14 @@ import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.JsonDataEntry; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.usagerecord.ApiLimitService; -import org.thingsboard.server.service.cf.CalculatedFieldResult; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -91,13 +95,17 @@ public class GeofencingCalculatedFieldStateTest { private ApiLimitService apiLimitService; @Mock private RelationService relationService; + @InjectMocks + private ActorSystemContext systemContext; @BeforeEach void setUp() { when(apiLimitService.getLimit(any(), any())).thenReturn(1000L); - ctx = new CalculatedFieldCtx(getCalculatedField(), null, apiLimitService, relationService); + ctx = new CalculatedFieldCtx(getCalculatedField(), systemContext); ctx.init(); - state = new GeofencingCalculatedFieldState(ctx.getArgNames()); + state = new GeofencingCalculatedFieldState(ctx.getEntityId()); + state.setCtx(ctx, null); + state.init(); } @Test @@ -113,7 +121,7 @@ public class GeofencingCalculatedFieldStateTest { )); Map newArgs = Map.of("allowedZones", geofencingAllowedZoneArgEntry); - boolean stateUpdated = state.updateState(ctx, newArgs); + boolean stateUpdated = !state.update(newArgs, ctx).isEmpty(); assertThat(stateUpdated).isTrue(); assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf( @@ -127,21 +135,21 @@ public class GeofencingCalculatedFieldStateTest { @Test void testUpdateStateWithInvalidArgumentTypeForLatitudeArgument() { - assertThatThrownBy(() -> state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry))) + assertThatThrownBy(() -> state.update(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry), ctx)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unsupported argument entry type for latitude argument: GEOFENCING. Only SINGLE_VALUE type is allowed."); } @Test void testUpdateStateWithInvalidArgumentTypeForLongitudeArgument() { - assertThatThrownBy(() -> state.updateState(ctx, Map.of(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry))) + assertThatThrownBy(() -> state.update(Map.of(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry), ctx)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unsupported argument entry type for longitude argument: GEOFENCING. Only SINGLE_VALUE type is allowed."); } @Test void testUpdateStateWithInvalidArgumentTypeForGeofencingArgument() { - assertThatThrownBy(() -> state.updateState(ctx, Map.of("someArgumentName", latitudeArgEntry))) + assertThatThrownBy(() -> state.update(Map.of("someArgumentName", latitudeArgEntry), ctx)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unsupported argument entry type for someArgumentName argument: SINGLE_VALUE. Only GEOFENCING type is allowed."); } @@ -152,7 +160,7 @@ public class GeofencingCalculatedFieldStateTest { SingleValueArgumentEntry newArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 190L); Map newArgs = Map.of("latitude", newArgEntry); - boolean stateUpdated = state.updateState(ctx, newArgs); + boolean stateUpdated = !state.update(newArgs, ctx).isEmpty(); assertThat(stateUpdated).isTrue(); assertThat(state.getArguments()).isEqualTo(newArgs); @@ -164,7 +172,7 @@ public class GeofencingCalculatedFieldStateTest { Map newArgs = Map.of("allowedZones", geofencingAllowedZoneArgEntry); - boolean stateUpdated = state.updateState(ctx, newArgs); + boolean stateUpdated = !state.update(newArgs, ctx).isEmpty(); assertThat(stateUpdated).isFalse(); assertThat(state.getArguments()).isEqualTo(newArgs); @@ -174,7 +182,7 @@ public class GeofencingCalculatedFieldStateTest { void testUpdateStateWhenUpdateExistingSingleValueArgumentEntryWithValueOfAnotherType() { state.arguments = new HashMap<>(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry)); - assertThatThrownBy(() -> state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry))) + assertThatThrownBy(() -> state.update(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry), ctx)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unsupported argument entry type for single value argument entry: GEOFENCING"); } @@ -184,7 +192,7 @@ public class GeofencingCalculatedFieldStateTest { void testUpdateStateWhenUpdateExistingGeofencingValueArgumentEntryWithValueOfAnotherType() { state.arguments = new HashMap<>(Map.of("allowedZones", geofencingAllowedZoneArgEntry)); - assertThatThrownBy(() -> state.updateState(ctx, Map.of("allowedZones", latitudeArgEntry))) + assertThatThrownBy(() -> state.update(Map.of("allowedZones", latitudeArgEntry), ctx)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unsupported argument entry type for geofencing argument entry: SINGLE_VALUE"); } @@ -192,31 +200,31 @@ public class GeofencingCalculatedFieldStateTest { @Test void testIsReadyWhenNotAllArgPresent() { assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()).contains(state.getRequiredArguments()); } @Test void testIsReadyWhenAllArgPresent() { - state.arguments = new HashMap<>(Map.of( + state.update(Map.of( ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, "allowedZones", geofencingAllowedZoneArgEntry, "restrictedZones", geofencingRestrictedZoneArgEntry - )); + ), ctx); assertThat(state.isReady()).isTrue(); + assertThat(state.getReadinessStatus().errorMsg()).isNull(); } @Test void testIsReadyWhenEmptyEntryPresents() { - state.arguments = new HashMap<>(Map.of( + state.update(Map.of( ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, "allowedZones", geofencingAllowedZoneArgEntry, - "restrictedZones", geofencingRestrictedZoneArgEntry - )); - - state.getArguments().put("noParkingZones", new GeofencingArgumentEntry()); - + "restrictedZones", new GeofencingArgumentEntry() + ), ctx); assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()).contains("restrictedZones"); } @Test @@ -234,7 +242,7 @@ public class GeofencingCalculatedFieldStateTest { when(relationService.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); when(relationService.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); - CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get(); + TelemetryCalculatedFieldResult result = performCalculation(); assertThat(result).isNotNull(); assertThat(result.getType()).isEqualTo(output.getType()); @@ -250,14 +258,14 @@ public class GeofencingCalculatedFieldStateTest { SingleValueArgumentEntry newLongitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", 30.5110), 166L); // move the device to new coordinates → leaves allowed, enters restricted - state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude)); + state.update(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude), ctx); - CalculatedFieldResult result2 = state.performCalculation(ctx.getEntityId(), ctx).get(); + TelemetryCalculatedFieldResult result2 = performCalculation(); assertThat(result2).isNotNull(); assertThat(result2.getType()).isEqualTo(output.getType()); assertThat(result2.getScope()).isEqualTo(output.getScope()); - assertThat(result2.getResult()).isEqualTo( + assertThat(result2.getResult().get("values")).isEqualTo( JacksonUtil.newObjectNode() .put("allowedZonesEvent", "LEFT") .put("allowedZonesStatus", "OUTSIDE") @@ -309,7 +317,7 @@ public class GeofencingCalculatedFieldStateTest { when(relationService.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); when(relationService.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); - CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get(); + TelemetryCalculatedFieldResult result = performCalculation(); assertThat(result).isNotNull(); assertThat(result.getType()).isEqualTo(output.getType()); @@ -322,14 +330,14 @@ public class GeofencingCalculatedFieldStateTest { SingleValueArgumentEntry newLongitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", 30.5110), 166L); // move the device to new coordinates → leaves allowed, enters restricted - state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude)); + state.update(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude), ctx); - CalculatedFieldResult result2 = state.performCalculation(ctx.getEntityId(), ctx).get(); + TelemetryCalculatedFieldResult result2 = performCalculation(); assertThat(result2).isNotNull(); assertThat(result2.getType()).isEqualTo(output.getType()); assertThat(result2.getScope()).isEqualTo(output.getScope()); - assertThat(result2.getResult()).isEqualTo( + assertThat(result2.getResult().get("values")).isEqualTo( JacksonUtil.newObjectNode() .put("allowedZonesEvent", "LEFT") .put("restrictedZonesEvent", "ENTERED") @@ -379,7 +387,7 @@ public class GeofencingCalculatedFieldStateTest { when(relationService.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); when(relationService.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); - CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get(); + TelemetryCalculatedFieldResult result = performCalculation(); assertThat(result).isNotNull(); assertThat(result.getType()).isEqualTo(output.getType()); @@ -394,14 +402,14 @@ public class GeofencingCalculatedFieldStateTest { SingleValueArgumentEntry newLongitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", 30.5110), 166L); // move the device to new coordinates → leaves allowed, enters restricted - state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude)); + state.update(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude), ctx); - CalculatedFieldResult result2 = state.performCalculation(ctx.getEntityId(), ctx).get(); + TelemetryCalculatedFieldResult result2 = performCalculation(); assertThat(result2).isNotNull(); assertThat(result2.getType()).isEqualTo(output.getType()); assertThat(result2.getScope()).isEqualTo(output.getScope()); - assertThat(result2.getResult()).isEqualTo( + assertThat(result2.getResult().get("values")).isEqualTo( JacksonUtil.newObjectNode() .put("allowedZonesStatus", "OUTSIDE") .put("restrictedZonesStatus", "INSIDE") @@ -453,21 +461,15 @@ public class GeofencingCalculatedFieldStateTest { config.setEntityCoordinates(entityCoordinates); ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("zone", reportStrategy, true); - var allowedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - allowedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.TO); - allowedZoneDynamicSourceConfiguration.setRelationType("AllowedZone"); - allowedZoneDynamicSourceConfiguration.setMaxLevel(1); - allowedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true); + var allowedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "AllowedZone"))); allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration); allowedZonesGroup.setRelationType("CurrentZone"); allowedZonesGroup.setDirection(EntitySearchDirection.TO); ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("zone", reportStrategy, true); - var restrictedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - restrictedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.TO); - restrictedZoneDynamicSourceConfiguration.setRelationType("RestrictedZone"); - restrictedZoneDynamicSourceConfiguration.setMaxLevel(1); - restrictedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true); + var restrictedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + restrictedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "RestrictedZone"))); restrictedZonesGroup.setRefDynamicSourceConfiguration(restrictedZoneDynamicSourceConfiguration); restrictedZonesGroup.setRelationType("CurrentZone"); restrictedZonesGroup.setDirection(EntitySearchDirection.TO); @@ -480,4 +482,8 @@ public class GeofencingCalculatedFieldStateTest { return config; } + private TelemetryCalculatedFieldResult performCalculation() throws InterruptedException, ExecutionException { + return (TelemetryCalculatedFieldResult) state.performCalculation(Collections.emptyMap(), ctx).get(); + } + } diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java new file mode 100644 index 0000000000..14a1b629c1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java @@ -0,0 +1,127 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfPropagationArg; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class PropagationArgumentEntryTest { + + private final AssetId ENTITY_1_ID = new AssetId(UUID.fromString("b0a8637d-6d67-43d5-a483-c0e391afe805")); + private final AssetId ENTITY_2_ID = new AssetId(UUID.fromString("7bd85073-ded5-414f-a2ef-bd56ad3dbf6a")); + private final AssetId ENTITY_3_ID = new AssetId(UUID.fromString("d64f3e51-2ec2-472f-b475-b095ef8bdc70")); + + private PropagationArgumentEntry entry; + + @BeforeEach + void setUp() { + List propagationEntityIds = new ArrayList<>(); + propagationEntityIds.add(ENTITY_1_ID); + propagationEntityIds.add(ENTITY_2_ID); + entry = new PropagationArgumentEntry(propagationEntityIds); + } + + @Test + void testArgumentEntryType() { + assertThat(entry.getType()).isEqualTo(ArgumentEntryType.PROPAGATION); + } + + @Test + void testIsEmpty() { + PropagationArgumentEntry emptyEntry = new PropagationArgumentEntry(List.of()); + assertThat(emptyEntry.isEmpty()).isTrue(); + } + + @Test + void testGetValueReturnsPropagationIds() { + assertThat(entry.getValue()).isInstanceOf(List.class); + @SuppressWarnings("unchecked") + List value = (List) entry.getValue(); + assertThat(value).containsExactly(ENTITY_1_ID, ENTITY_2_ID); + } + + @Test + void testUpdateEntryWhenSingleEntryPassed() { + assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for propagation argument entry: SINGLE_VALUE"); + } + + @Test + void testUpdateEntryWhenRollingEntryPassed() { + assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for propagation argument entry: TS_ROLLING"); + } + + @Test + void testUpdateEntryReplacesWithNewIds() { + var newIds = new ArrayList(List.of(ENTITY_3_ID, ENTITY_1_ID)); + var updated = new PropagationArgumentEntry(newIds); + + boolean changed = entry.updateEntry(updated); + + assertThat(changed).isTrue(); + assertThat(entry.getPropagationEntityIds()).containsExactlyElementsOf(newIds); + } + + @Test + void testUpdateEntryClearsWhenNewEntryIsEmpty() { + var updatedEmpty = new PropagationArgumentEntry(List.of()); + + boolean changed = entry.updateEntry(updatedEmpty); + + assertThat(changed).isTrue(); + assertThat(entry.getPropagationEntityIds()).isEmpty(); + } + + @Test + @SuppressWarnings("unchecked") + void testToTbelCfArgWithValues() { + TbelCfArg arg = entry.toTbelCfArg(); + assertThat(arg).isInstanceOf(TbelCfPropagationArg.class); + + TbelCfPropagationArg tbelCfPropagationArg = (TbelCfPropagationArg) arg; + assertThat(tbelCfPropagationArg.getValue()).isInstanceOf(List.class); + assertThat((List) tbelCfPropagationArg.getValue()).containsExactly(ENTITY_1_ID, ENTITY_2_ID); + } + + + @Test + @SuppressWarnings("unchecked") + void testToTbelCfArgWithEmptyValues() { + var empty = new PropagationArgumentEntry(List.of()); + TbelCfArg emptyArg = empty.toTbelCfArg(); + assertThat(emptyArg).isInstanceOf(TbelCfPropagationArg.class); + + TbelCfPropagationArg tbelCfPropagationArg = (TbelCfPropagationArg) emptyArg; + assertThat(tbelCfPropagationArg.getValue()).isInstanceOf(List.class); + assertThat((List) tbelCfPropagationArg.getValue()).isEmpty(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java new file mode 100644 index 0000000000..ddb9f378b0 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java @@ -0,0 +1,249 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.script.api.tbel.DefaultTbelInvokeService; +import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; +import org.thingsboard.server.common.stats.DefaultStatsFactory; +import org.thingsboard.server.dao.usagerecord.ApiLimitService; +import org.thingsboard.server.service.cf.PropagationCalculatedFieldResult; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutionException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; + +@SpringBootTest(classes = {SimpleMeterRegistry.class, DefaultStatsFactory.class, DefaultTbelInvokeService.class}) +public class PropagationCalculatedFieldStateTest { + + private static final String TEMPERATURE_ARGUMENT_NAME = "t"; + private static final String TEST_RESULT_EXPRESSION_KEY = "testResult"; + private static final double TEMPERATURE_VALUE = 12.5; + + private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("6c3513cb-85e7-4510-8746-1ba01859a8ce")); + private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("be960a50-c029-4698-b2ec-c56a543c561c")); + private final AssetId ASSET_ID_1 = new AssetId(UUID.fromString("d26f0e5b-7d7d-4a61-9f5e-08ab97b30734")); + private final AssetId ASSET_ID_2 = new AssetId(UUID.fromString("1933a317-4df5-4d36-9800-68aded74579b")); + + private final SingleValueArgumentEntry singleValueArgEntry = + new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("temperature", TEMPERATURE_VALUE), 99L); + + private final PropagationArgumentEntry propagationArgEntry = + new PropagationArgumentEntry(new ArrayList<>(List.of(ASSET_ID_2, ASSET_ID_1))); + + private PropagationCalculatedFieldState state; + private CalculatedFieldCtx ctx; + + @Autowired + private TbelInvokeService tbelInvokeService; + + @MockitoBean + private ApiLimitService apiLimitService; + + @MockitoBean + private ActorSystemContext actorSystemContext; + + @BeforeEach + void setUp() { + when(actorSystemContext.getTbelInvokeService()).thenReturn(tbelInvokeService); + when(actorSystemContext.getApiLimitService()).thenReturn(apiLimitService); + when(apiLimitService.getLimit(any(), any())).thenReturn(1000L); + } + + void initCtxAndState(boolean applyExpressionToResolvedArguments) { + ctx = new CalculatedFieldCtx(getCalculatedField(applyExpressionToResolvedArguments), actorSystemContext); + ctx.init(); + + state = new PropagationCalculatedFieldState(ctx.getEntityId()); + state.setCtx(ctx, null); + state.init(); + } + + @Test + void testType() { + initCtxAndState(false); + assertThat(state.getType()).isEqualTo(CalculatedFieldType.PROPAGATION); + } + + @Test + void testInitAddsRequiredArgument() { + initCtxAndState(false); + assertThat(state.getRequiredArguments()).containsExactlyInAnyOrder(TEMPERATURE_ARGUMENT_NAME, PROPAGATION_CONFIG_ARGUMENT); + } + + @Test + void testIsReadyReturnFalseWhenNoArgumentsSet() { + initCtxAndState(false); + assertThat(state.isReady()).isFalse(); + } + + @Test + void testIsReadyWhenPropagationArgIsNull() { + initCtxAndState(false); + state.update(Map.of(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry), ctx); + assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()).contains(PROPAGATION_CONFIG_ARGUMENT); + } + + @Test + void testIsReadyWhenPropagationArgIsEmpty() { + initCtxAndState(false); + state.update(Map.of(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry, + PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(Collections.emptyList())), ctx); + assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()).contains(PROPAGATION_CONFIG_ARGUMENT); + } + + @Test + void testIsReadyWhenPropagationArgHasEntities() { + initCtxAndState(false); + state.update(Map.of(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry, PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry), ctx); + assertThat(state.isReady()).isTrue(); + assertThat(state.getReadinessStatus().errorMsg()).isNull(); + } + + + @Test + void testPerformCalculationWithEmptyPropagationArg() throws Exception { + initCtxAndState(false); + state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(Collections.emptyList())); + + PropagationCalculatedFieldResult result = performCalculation(); + + assertThat(result).isNotNull(); + assertThat(result.isEmpty()).isTrue(); + assertThat(result.getPropagationEntityIds()).isNullOrEmpty(); + } + + @Test + void testPerformCalculationWithArgumentsOnlyMode() throws Exception { + initCtxAndState(false); + state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry); + state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry); + + PropagationCalculatedFieldResult propagationResult = performCalculation(); + + assertThat(propagationResult).isNotNull(); + assertThat(propagationResult.isEmpty()).isFalse(); + assertThat(propagationResult.getPropagationEntityIds()).containsExactly(ASSET_ID_2, ASSET_ID_1); + + TelemetryCalculatedFieldResult result = propagationResult.getResult(); + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(OutputType.ATTRIBUTES); + assertThat(result.getScope()).isEqualTo(AttributeScope.SERVER_SCOPE); + + ObjectNode expectedNode = JacksonUtil.newObjectNode(); + JacksonUtil.addKvEntry(expectedNode, singleValueArgEntry.getKvEntryValue(), TEMPERATURE_ARGUMENT_NAME); + + assertThat(result.getResult()).isEqualTo(expectedNode); + } + + @Test + void testPerformCalculationWithExpressionResultMode() throws Exception { + initCtxAndState(true); + state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry); + state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry); + + PropagationCalculatedFieldResult propagationResult = performCalculation(); + + assertThat(propagationResult).isNotNull(); + assertThat(propagationResult.isEmpty()).isFalse(); + assertThat(propagationResult.getPropagationEntityIds()).containsExactly(ASSET_ID_2, ASSET_ID_1); + + TelemetryCalculatedFieldResult result = propagationResult.getResult(); + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(OutputType.ATTRIBUTES); + assertThat(result.getScope()).isEqualTo(AttributeScope.SERVER_SCOPE); + + ObjectNode expectedNode = JacksonUtil.newObjectNode(); + expectedNode.put(TEST_RESULT_EXPRESSION_KEY, TEMPERATURE_VALUE * 2); + + assertThat(result.getResult()).isEqualTo(expectedNode); + } + + private CalculatedField getCalculatedField(boolean applyExpressionToResolvedArguments) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setTenantId(TENANT_ID); + calculatedField.setEntityId(DEVICE_ID); + calculatedField.setType(CalculatedFieldType.PROPAGATION); + calculatedField.setName("Test Propagation CF"); + calculatedField.setConfigurationVersion(1); + calculatedField.setConfiguration(getCalculatedFieldConfig(applyExpressionToResolvedArguments)); + calculatedField.setVersion(1L); + return calculatedField; + } + + private CalculatedFieldConfiguration getCalculatedFieldConfig(boolean applyExpressionToResolvedArguments) { + var config = new PropagationCalculatedFieldConfiguration(); + + config.setRelation(new RelationPathLevel(EntitySearchDirection.TO, EntityRelation.CONTAINS_TYPE)); + config.setApplyExpressionToResolvedArguments(applyExpressionToResolvedArguments); + + Argument temperatureArg = new Argument(); + ReferencedEntityKey tempKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + temperatureArg.setRefEntityKey(tempKey); + + config.setArguments(Map.of(TEMPERATURE_ARGUMENT_NAME, temperatureArg)); + config.setExpression("{" + TEST_RESULT_EXPRESSION_KEY + ": " + TEMPERATURE_ARGUMENT_NAME + " * 2}"); + + Output output = new Output(); + output.setType(OutputType.ATTRIBUTES); + output.setScope(AttributeScope.SERVER_SCOPE); + config.setOutput(output); + + return config; + } + + private PropagationCalculatedFieldResult performCalculation() throws ExecutionException, InterruptedException { + return (PropagationCalculatedFieldResult) state.performCalculation(Collections.emptyMap(), ctx).get(); + } +} diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesArgumentEntryTest.java new file mode 100644 index 0000000000..cc60b249ac --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesArgumentEntryTest.java @@ -0,0 +1,100 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.ctx.state; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class RelatedEntitiesArgumentEntryTest { + + private RelatedEntitiesArgumentEntry entry; + + private final DeviceId device1 = new DeviceId(UUID.fromString("1984e5f4-9ff0-4187-84ae-e4438bba4c8a")); + private final DeviceId device2 = new DeviceId(UUID.fromString("937fc062-1a9d-438f-aa22-55a93fc908b7")); + + private final long ts = System.currentTimeMillis(); + + @BeforeEach + void setUp() { + Map aggInputs = new HashMap<>(); + aggInputs.put(device1, new SingleValueArgumentEntry(device1, new BasicTsKvEntry(ts - 100, new LongDataEntry("key", 12L), 1L))); + aggInputs.put(device2, new SingleValueArgumentEntry(device2, new BasicTsKvEntry(ts - 150, new LongDataEntry("key", 16L), 6L))); + + entry = new RelatedEntitiesArgumentEntry(aggInputs, false); + } + + @Test + void testUpdateEntryWhenNotAggEntryPassed() { + assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for aggregation argument entry: " + ArgumentEntryType.TS_ROLLING); + } + + @Test + void testUpdateEntryWhenAggArgumentEntryPasser() { + DeviceId device3 = new DeviceId(UUID.randomUUID()); + DeviceId device4 = new DeviceId(UUID.randomUUID()); + + RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry = new RelatedEntitiesArgumentEntry(Map.of( + device3, new SingleValueArgumentEntry(device3, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 16L), 13L)), + device4, new SingleValueArgumentEntry(device4, new BasicTsKvEntry(ts - 60, new LongDataEntry("key", 23L), 7L)) + ), false); + + assertThat(entry.updateEntry(relatedEntitiesArgumentEntry)).isTrue(); + + Map aggInputs = entry.getEntityInputs(); + assertThat(aggInputs.size()).isEqualTo(4); + assertThat(aggInputs.get(device3)).isEqualTo(relatedEntitiesArgumentEntry.getEntityInputs().get(device3)); + assertThat(aggInputs.get(device4)).isEqualTo(relatedEntitiesArgumentEntry.getEntityInputs().get(device4)); + } + + @Test + void testUpdateEntryWhenSingleValueArgumentEntryPassedAndNoEntriesById() { + DeviceId device3 = new DeviceId(UUID.randomUUID()); + + SingleValueArgumentEntry singleEntityArgumentEntry = new SingleValueArgumentEntry(device3, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 18L), 10L)); + + assertThat(entry.updateEntry(singleEntityArgumentEntry)).isTrue(); + + Map aggInputs = entry.getEntityInputs(); + assertThat(aggInputs.size()).isEqualTo(3); + assertThat(aggInputs.get(device3)).isEqualTo(singleEntityArgumentEntry); + } + + @Test + void testUpdateEntryWhenSingleValueArgumentEntryPassedAndEntryByIdExist() { + SingleValueArgumentEntry singleEntityArgumentEntry = new SingleValueArgumentEntry(device2, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 18L), 10L)); + + assertThat(entry.updateEntry(singleEntityArgumentEntry)).isTrue(); + + Map aggInputs = entry.getEntityInputs(); + assertThat(aggInputs.size()).isEqualTo(2); + assertThat(aggInputs.get(device2)).isEqualTo(singleEntityArgumentEntry); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java index 8c714bc0e7..51e633a232 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java @@ -18,12 +18,14 @@ package org.thingsboard.server.service.cf.ctx.state; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.tbel.DefaultTbelInvokeService; import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; @@ -41,10 +43,10 @@ import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.stats.DefaultStatsFactory; import org.thingsboard.server.dao.usagerecord.ApiLimitService; -import org.thingsboard.server.service.cf.CalculatedFieldResult; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; +import java.util.Collections; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.TreeMap; import java.util.UUID; @@ -77,10 +79,16 @@ public class ScriptCalculatedFieldStateTest { @BeforeEach void setUp() { + ActorSystemContext systemContext = Mockito.mock(ActorSystemContext.class); + when(systemContext.getTbelInvokeService()).thenReturn(tbelInvokeService); + when(systemContext.getApiLimitService()).thenReturn(apiLimitService); + when(apiLimitService.getLimit(any(), any())).thenReturn(1000L); - ctx = new CalculatedFieldCtx(getCalculatedField(), tbelInvokeService, apiLimitService, null); + ctx = new CalculatedFieldCtx(getCalculatedField(), systemContext); ctx.init(); - state = new ScriptCalculatedFieldState(ctx.getArgNames()); + state = new ScriptCalculatedFieldState(ctx.getEntityId()); + state.setCtx(ctx, null); + state.init(); } @Test @@ -93,7 +101,7 @@ public class ScriptCalculatedFieldStateTest { state.arguments = new HashMap<>(Map.of("assetHumidity", assetHumidityArgEntry)); Map newArgs = Map.of("deviceTemperature", deviceTemperatureArgEntry); - boolean stateUpdated = state.updateState(ctx, newArgs); + boolean stateUpdated = !state.update(newArgs, ctx).isEmpty(); assertThat(stateUpdated).isTrue(); assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf( @@ -110,7 +118,7 @@ public class ScriptCalculatedFieldStateTest { SingleValueArgumentEntry newArgEntry = new SingleValueArgumentEntry(ts, new LongDataEntry("assetHumidity", 41L), 349L); Map newArgs = Map.of("assetHumidity", newArgEntry); - boolean stateUpdated = state.updateState(ctx, newArgs); + boolean stateUpdated = !state.update(newArgs, ctx).isEmpty(); assertThat(stateUpdated).isTrue(); assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf( @@ -125,7 +133,7 @@ public class ScriptCalculatedFieldStateTest { void testPerformCalculation() throws ExecutionException, InterruptedException { state.arguments = new HashMap<>(Map.of("deviceTemperature", deviceTemperatureArgEntry, "assetHumidity", assetHumidityArgEntry)); - CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get(); + TelemetryCalculatedFieldResult result = performCalculation(); assertThat(result).isNotNull(); Output output = getCalculatedFieldConfig().getOutput(); @@ -141,7 +149,7 @@ public class ScriptCalculatedFieldStateTest { "assetHumidity", new SingleValueArgumentEntry(System.currentTimeMillis() - 10, new LongDataEntry("a", 45L), 10L) )); - CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get(); + TelemetryCalculatedFieldResult result = performCalculation(); assertThat(result).isNotNull(); Output output = getCalculatedFieldConfig().getOutput(); @@ -153,20 +161,21 @@ public class ScriptCalculatedFieldStateTest { @Test void testIsReadyWhenNotAllArgPresent() { assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()).contains(state.getRequiredArguments()); } @Test void testIsReadyWhenAllArgPresent() { - state.arguments = new HashMap<>(Map.of("deviceTemperature", deviceTemperatureArgEntry, "assetHumidity", assetHumidityArgEntry)); - + state.update(Map.of("deviceTemperature", deviceTemperatureArgEntry, "assetHumidity", assetHumidityArgEntry), ctx); assertThat(state.isReady()).isTrue(); + assertThat(state.getReadinessStatus().errorMsg()).isNull(); } @Test void testIsReadyWhenEmptyEntryPresents() { - state.arguments = new HashMap<>(Map.of("deviceTemperature", new TsRollingArgumentEntry(5, 30000L), "assetHumidity", assetHumidityArgEntry)); - + state.update(Map.of("deviceTemperature", new TsRollingArgumentEntry(5, 30000L), "assetHumidity", assetHumidityArgEntry), ctx); assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()).contains("deviceTemperature"); } private TsRollingArgumentEntry createRollingArgEntry() { @@ -221,4 +230,8 @@ public class ScriptCalculatedFieldStateTest { return config; } + private TelemetryCalculatedFieldResult performCalculation() throws InterruptedException, ExecutionException { + return (TelemetryCalculatedFieldResult) state.performCalculation(Collections.emptyMap(), ctx).get(); + } + } \ No newline at end of file diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java index 8c631ecf6f..a64f1e4c60 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java @@ -18,9 +18,11 @@ package org.thingsboard.server.service.cf.ctx.state; 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.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; @@ -39,8 +41,9 @@ import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.dao.usagerecord.ApiLimitService; -import org.thingsboard.server.service.cf.CalculatedFieldResult; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -67,13 +70,17 @@ public class SimpleCalculatedFieldStateTest { @Mock private ApiLimitService apiLimitService; + @InjectMocks + private ActorSystemContext systemContext; @BeforeEach void setUp() { when(apiLimitService.getLimit(any(), any())).thenReturn(1000L); - ctx = new CalculatedFieldCtx(getCalculatedField(), null, apiLimitService, null); + ctx = new CalculatedFieldCtx(getCalculatedField(), systemContext); ctx.init(); - state = new SimpleCalculatedFieldState(ctx.getArgNames()); + state = new SimpleCalculatedFieldState(ctx.getEntityId()); + state.setCtx(ctx, null); + state.init(); } @Test @@ -89,7 +96,7 @@ public class SimpleCalculatedFieldStateTest { )); Map newArgs = Map.of("key3", key3ArgEntry); - boolean stateUpdated = state.updateState(ctx, newArgs); + boolean stateUpdated = !state.update(newArgs, ctx).isEmpty(); assertThat(stateUpdated).isTrue(); assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf( @@ -107,7 +114,7 @@ public class SimpleCalculatedFieldStateTest { SingleValueArgumentEntry newArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis(), new LongDataEntry("key1", 18L), 190L); Map newArgs = Map.of("key1", newArgEntry); - boolean stateUpdated = state.updateState(ctx, newArgs); + boolean stateUpdated = !state.update(newArgs, ctx).isEmpty(); assertThat(stateUpdated).isTrue(); assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf(Map.of("key1", newArgEntry)); @@ -121,9 +128,10 @@ public class SimpleCalculatedFieldStateTest { )); Map newArgs = Map.of("key3", new TsRollingArgumentEntry(10, 30000L)); - assertThatThrownBy(() -> state.updateState(ctx, newArgs)) + assertThatThrownBy(() -> state.update(newArgs, ctx)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Rolling argument entry is not supported for simple calculated fields."); + .hasMessage("Unsupported argument type detected for argument: key3. " + + "Rolling argument entry is not supported for simple calculated fields."); } @Test @@ -134,7 +142,7 @@ public class SimpleCalculatedFieldStateTest { "key3", key3ArgEntry )); - CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get(); + TelemetryCalculatedFieldResult result = performCalculation(); assertThat(result).isNotNull(); Output output = getCalculatedFieldConfig().getOutput(); @@ -151,7 +159,7 @@ public class SimpleCalculatedFieldStateTest { "key3", key3ArgEntry )); - assertThatThrownBy(() -> state.performCalculation(ctx.getEntityId(), ctx)) + assertThatThrownBy(() -> state.performCalculation(Collections.emptyMap(), ctx)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Argument 'key2' is not a number."); } @@ -164,7 +172,7 @@ public class SimpleCalculatedFieldStateTest { "key3", key3ArgEntry )); - CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get(); + TelemetryCalculatedFieldResult result = performCalculation(); assertThat(result).isNotNull(); Output output = getCalculatedFieldConfig().getOutput(); @@ -185,7 +193,7 @@ public class SimpleCalculatedFieldStateTest { output.setDecimalsByDefault(3); ctx.setOutput(output); - CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get(); + TelemetryCalculatedFieldResult result = performCalculation(); assertThat(result).isNotNull(); assertThat(result.getType()).isEqualTo(output.getType()); @@ -196,28 +204,29 @@ public class SimpleCalculatedFieldStateTest { @Test void testIsReadyWhenNotAllArgPresent() { assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()).contains(state.getRequiredArguments()); } @Test void testIsReadyWhenAllArgPresent() { - state.arguments = new HashMap<>(Map.of( + state.update(Map.of( "key1", key1ArgEntry, "key2", key2ArgEntry, "key3", key3ArgEntry - )); - + ), ctx); assertThat(state.isReady()).isTrue(); + assertThat(state.getReadinessStatus().errorMsg()).isNull(); } @Test void testIsReadyWhenEmptyEntryPresents() { - state.arguments = new HashMap<>(Map.of( + state.update(Map.of( "key1", key1ArgEntry, - "key2", key2ArgEntry - )); - state.getArguments().put("key3", new SingleValueArgumentEntry()); - + "key2", key2ArgEntry, + "key3", new SingleValueArgumentEntry() + ), ctx); assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()).contains("key3"); } private CalculatedField getCalculatedField() { @@ -265,4 +274,8 @@ public class SimpleCalculatedFieldStateTest { return config; } + private TelemetryCalculatedFieldResult performCalculation() throws InterruptedException, ExecutionException { + return (TelemetryCalculatedFieldResult) state.performCalculation(Collections.emptyMap(), ctx).get(); + } + } \ No newline at end of file diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java index 5ea3808468..4ada355054 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java @@ -57,6 +57,11 @@ public class SingleValueArgumentEntryTest { assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts, new LongDataEntry("key", 13L), 363L))).isFalse(); } + @Test + void testUpdateEntryWithTheSameTsAndDifferentVersion() { + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts, new LongDataEntry("key", 13L), 364L))).isTrue(); + } + @Test void testUpdateEntryWhenNewVersionIsNull() { assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 16, new LongDataEntry("key", 13L), null))).isTrue(); @@ -115,4 +120,5 @@ public class SingleValueArgumentEntryTest { expectedList.add(Map.of("test2", 20)); assertThat(singleValueArg.getValue()).isEqualTo(expectedList); } + } diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java index 2321446b44..b6be136f82 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java @@ -253,7 +253,7 @@ class DefaultTbAiModelServiceTest { private static AiModelConfig constructValidOpenAiModelConfig() { return OpenAiChatModelConfig.builder() - .providerConfig(new OpenAiProviderConfig("test-api-key")) + .providerConfig(OpenAiProviderConfig.builder().apiKey("test-api-key").build()) .modelId("gpt-4o") .temperature(0.5) .topP(0.3) diff --git a/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java b/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java index 0069310b5d..7806215a51 100644 --- a/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java @@ -98,7 +98,7 @@ public class JobManagerTest extends AbstractControllerTest { await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { Job job = findJobById(jobId); assertThat(job.getStatus()).isEqualTo(JobStatus.RUNNING); - assertThat(job.getResult().getSuccessfulCount()).isBetween(1, tasksCount - 1); + assertThat(job.getResult().getSuccessfulCount()).isBetween(0, tasksCount - 1); assertThat(job.getResult().getTotalCount()).isEqualTo(tasksCount); }); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { diff --git a/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest_EntityPartitioningStrategy.java b/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest_EntityPartitioningStrategy.java index 59093ad802..c1b8f911ff 100644 --- a/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest_EntityPartitioningStrategy.java +++ b/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest_EntityPartitioningStrategy.java @@ -27,17 +27,16 @@ import org.thingsboard.server.dao.service.DaoSqlTest; public class JobManagerTest_EntityPartitioningStrategy extends JobManagerTest { /* - * Some tests are overridden because they are based on - * tenant partitioning strategy (subsequent tasks processing within a tenant) - * */ + * Some tests are overridden because they are based on + * tenant partitioning strategy (subsequent tasks processing within a tenant) + * */ @Override - public void testCancelJob_simulateTaskProcessorRestart() throws Exception { + public void testCancelJob_simulateTaskProcessorRestart() { } @Override public void testSubmitJob_generalError() { - } } diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java index bab70ea505..ff49a68d66 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java @@ -22,12 +22,12 @@ import org.junit.Before; import org.junit.Test; import org.junit.function.ThrowingRunnable; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.data.util.Pair; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cache.limits.RateLimitService; -import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityType; @@ -39,17 +39,19 @@ import org.thingsboard.server.common.data.alarm.AlarmCommentType; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; import org.thingsboard.server.common.data.alarm.AlarmSeverity; import org.thingsboard.server.common.data.alarm.AlarmStatus; +import org.thingsboard.server.common.data.alarm.rule.AlarmRule; +import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration; import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; import org.thingsboard.server.common.data.device.data.DeviceData; -import org.thingsboard.server.common.data.device.profile.AlarmCondition; -import org.thingsboard.server.common.data.device.profile.AlarmConditionFilter; -import org.thingsboard.server.common.data.device.profile.AlarmConditionFilterKey; -import org.thingsboard.server.common.data.device.profile.AlarmConditionKeyType; -import org.thingsboard.server.common.data.device.profile.AlarmRule; -import org.thingsboard.server.common.data.device.profile.DeviceProfileAlarm; -import org.thingsboard.server.common.data.device.profile.SimpleAlarmConditionSpec; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.DeviceId; @@ -87,9 +89,6 @@ import org.thingsboard.server.common.data.notification.targets.platform.SystemAd import org.thingsboard.server.common.data.notification.template.NotificationTemplate; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.common.data.query.BooleanFilterPredicate; -import org.thingsboard.server.common.data.query.EntityKeyValueType; -import org.thingsboard.server.common.data.query.FilterPredicateValue; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.common.data.security.Authority; @@ -106,12 +105,10 @@ import org.thingsboard.server.service.system.DefaultSystemInfoService; import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; import java.lang.reflect.Method; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.TreeMap; import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; @@ -137,7 +134,7 @@ import static org.thingsboard.server.common.data.notification.rule.trigger.confi }) public class NotificationRuleApiTest extends AbstractNotificationApiTest { - @SpyBean + @MockitoSpyBean private AlarmSubscriptionService alarmSubscriptionService; @Autowired private DefaultSystemInfoService systemInfoService; @@ -193,7 +190,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { @Test public void testNotificationRuleProcessing_alarmTrigger() throws Exception { String notificationSubject = "Alarm type: ${alarmType}, status: ${alarmStatus}, " + - "severity: ${alarmSeverity}, deviceId: ${alarmOriginatorId}"; + "severity: ${alarmSeverity}, deviceId: ${alarmOriginatorId}, details: ${details.data}."; String notificationText = "Status: ${alarmStatus}, severity: ${alarmSeverity}"; NotificationTemplate notificationTemplate = createNotificationTemplate(NotificationType.ALARM, notificationSubject, notificationText, NotificationDeliveryMethod.WEB); @@ -221,12 +218,12 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { clients.put(delay, userAndClient.getSecond()); } notificationRule.setRecipientsConfig(recipientsConfig); - notificationRule = saveNotificationRule(notificationRule); + saveNotificationRule(notificationRule); String alarmType = "myBoolIsTrue"; DeviceProfile deviceProfile = createDeviceProfileWithAlarmRules(alarmType); - Device device = createDevice("Device 1", deviceProfile.getName(), "1234"); + Device device = createDevice("Device 1", deviceProfile.getName(), "label", "1234"); clients.values().forEach(wsClient -> { wsClient.subscribeForUnreadNotifications(10).waitForReply(true); @@ -234,8 +231,8 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { }); JsonNode attr = JacksonUtil.newObjectNode() - .set("bool", BooleanNode.TRUE); - doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE, attr); + .set("createAlarm", BooleanNode.TRUE); + postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, attr.toString()); await().atMost(10, TimeUnit.SECONDS) .until(() -> alarmSubscriptionService.findLatestByOriginatorAndType(tenantId, device.getId(), alarmType) != null); @@ -250,7 +247,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { assertThat(actualDelay).isCloseTo(expectedDelay, offset(2.0)); assertThat(notification.getSubject()).isEqualTo("Alarm type: " + alarmType + ", status: " + AlarmStatus.ACTIVE_UNACK + ", " + - "severity: " + AlarmSeverity.CRITICAL.toString().toLowerCase() + ", deviceId: " + device.getId()); + "severity: " + AlarmSeverity.CRITICAL.toString().toLowerCase() + ", deviceId: " + device.getId() + ", details: attribute is true."); assertThat(notification.getText()).isEqualTo("Status: " + AlarmStatus.ACTIVE_UNACK + ", severity: " + AlarmSeverity.CRITICAL.toString().toLowerCase()); assertThat(notification.getType()).isEqualTo(NotificationType.ALARM); @@ -270,7 +267,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { wsClient.waitForUpdate(true); Notification updatedNotification = wsClient.getLastDataUpdate().getUpdate(); assertThat(updatedNotification.getSubject()).isEqualTo("Alarm type: " + alarmType + ", status: " + expectedStatus + ", " + - "severity: " + expectedSeverity.toString().toLowerCase() + ", deviceId: " + device.getId()); + "severity: " + expectedSeverity.toString().toLowerCase() + ", deviceId: " + device.getId() + ", details: attribute is true."); assertThat(updatedNotification.getText()).isEqualTo("Status: " + expectedStatus + ", severity: " + expectedSeverity.toString().toLowerCase()); wsClient.close(); @@ -341,8 +338,8 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { getWsClient().subscribeForUnreadNotifications(10).waitForReply(true); getWsClient().registerWaitForUpdate(); JsonNode attr = JacksonUtil.newObjectNode() - .set("bool", BooleanNode.TRUE); - doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE, attr); + .set("createAlarm", BooleanNode.TRUE); + postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, attr.toString()); await().atMost(10, TimeUnit.SECONDS) .until(() -> alarmSubscriptionService.findLatestByOriginatorAndType(tenantId, device.getId(), alarmType) != null); @@ -516,10 +513,10 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { .notifyOn(Set.of(ASSIGNED, UNASSIGNED)) .build(); NotificationTarget target = createNotificationTarget(tenantAdminUserId); - String template = "${userEmail} ${action} alarm on ${alarmOriginatorEntityType} '${alarmOriginatorName}'. Assignee: ${assigneeEmail}"; + String template = "${userEmail} ${action} alarm on ${alarmOriginatorEntityType} '${alarmOriginatorName}' with label '${alarmOriginatorLabel}'. Assignee: ${assigneeEmail}"; createNotificationRule(triggerConfig, "Test", template, target.getId()); - Device device = createDevice("Device A", "123"); + Device device = createDevice("Device A", "default", "test", "123"); Alarm alarm = Alarm.builder() .tenantId(tenantId) .originator(device.getId()) @@ -536,7 +533,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { doPost("/api/alarm/" + alarmId + "/assign/" + tenantAdminUserId).andExpect(status().isOk()); }, notification -> { assertThat(notification.getText()).isEqualTo( - TENANT_ADMIN_EMAIL + " assigned alarm on Device 'Device A'. Assignee: " + TENANT_ADMIN_EMAIL + TENANT_ADMIN_EMAIL + " assigned alarm on Device 'Device A' with label 'test'. Assignee: " + TENANT_ADMIN_EMAIL ); }); @@ -544,7 +541,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { doDelete("/api/alarm/" + alarmId + "/assign").andExpect(status().isOk()); }, notification -> { assertThat(notification.getText()).isEqualTo( - TENANT_ADMIN_EMAIL + " unassigned alarm on Device 'Device A'. Assignee: " + TENANT_ADMIN_EMAIL + " unassigned alarm on Device 'Device A' with label 'test'. Assignee: " ); }); } @@ -944,35 +941,28 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { private DeviceProfile createDeviceProfileWithAlarmRules(String alarmType) { DeviceProfile deviceProfile = createDeviceProfile("For notification rule test"); deviceProfile.setTenantId(tenantId); + deviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); - List alarms = new ArrayList<>(); - DeviceProfileAlarm alarm = new DeviceProfileAlarm(); - alarm.setAlarmType(alarmType); - alarm.setId(alarmType); + CalculatedField alarmCf = new CalculatedField(); + alarmCf.setType(CalculatedFieldType.ALARM); + alarmCf.setEntityId(deviceProfile.getId()); + alarmCf.setName(alarmType); + AlarmCalculatedFieldConfiguration configuration = new AlarmCalculatedFieldConfiguration(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("createAlarm", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + configuration.setArguments(Map.of("createAlarm", argument)); AlarmRule alarmRule = new AlarmRule(); - alarmRule.setAlarmDetails("Details"); - AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setSpec(new SimpleAlarmConditionSpec()); - List condition = new ArrayList<>(); - - AlarmConditionFilter alarmConditionFilter = new AlarmConditionFilter(); - alarmConditionFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.ATTRIBUTE, "bool")); - BooleanFilterPredicate predicate = new BooleanFilterPredicate(); - predicate.setOperation(BooleanFilterPredicate.BooleanOperation.EQUAL); - predicate.setValue(new FilterPredicateValue<>(true)); - - alarmConditionFilter.setPredicate(predicate); - alarmConditionFilter.setValueType(EntityKeyValueType.BOOLEAN); - condition.add(alarmConditionFilter); - alarmCondition.setCondition(condition); - alarmRule.setCondition(alarmCondition); - TreeMap createRules = new TreeMap<>(); - createRules.put(AlarmSeverity.CRITICAL, alarmRule); - alarm.setCreateRules(createRules); - alarms.add(alarm); - - deviceProfile.getProfileData().setAlarms(alarms); - deviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + alarmRule.setAlarmDetails("attribute is ${createAlarm}"); + SimpleAlarmCondition condition = new SimpleAlarmCondition(); + TbelAlarmConditionExpression expression = new TbelAlarmConditionExpression(); + expression.setExpression("return createAlarm == true;"); + condition.setExpression(expression); + alarmRule.setCondition(condition); + configuration.setCreateRules(Map.of( + AlarmSeverity.CRITICAL, alarmRule + )); + alarmCf.setConfiguration(configuration); + saveCalculatedField(alarmCf); return deviceProfile; } 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 bcbe52b5c9..ace27c08c1 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 @@ -595,7 +595,7 @@ public class TbRuleEngineQueueConsumerManagerTest { await().atMost(5, TimeUnit.SECONDS).until(() -> { for (TopicPartitionInfo partition : expectedPartitions) { if (consumers.stream().noneMatch(consumer -> consumer.subscribed && - consumer.pollingStarted && Set.of(partition).equals(consumer.getPartitions()))) { + consumer.pollingStarted && Set.of(partition).equals(consumer.getPartitions()))) { return false; } } @@ -605,7 +605,7 @@ public class TbRuleEngineQueueConsumerManagerTest { await().atMost(5, TimeUnit.SECONDS).until(() -> { return consumers.size() == 1 && consumers.stream() .anyMatch(consumer -> consumer.subscribed && consumer.pollingStarted && - expectedPartitions.equals(consumer.getPartitions())); + expectedPartitions.equals(consumer.getPartitions())); }); } Mockito.reset(ruleEngineConsumerContext.getSubmitStrategyFactory()); @@ -667,8 +667,8 @@ public class TbRuleEngineQueueConsumerManagerTest { return await().atMost(5, TimeUnit.SECONDS) .until(() -> consumers.stream() .filter(consumer -> consumer.getPartitions() != null && - consumer.getPartitions().size() == 1 && - consumer.getPartitions().contains(tpi)) + consumer.getPartitions().size() == 1 && + consumer.getPartitions().contains(tpi)) .findFirst().orElse(null), Objects::nonNull); } @@ -676,9 +676,9 @@ public class TbRuleEngineQueueConsumerManagerTest { return await().atMost(5, TimeUnit.SECONDS) .until(() -> consumers.stream() .filter(consumer -> consumer.getPartitions() != null && - consumer.getPartitions().size() == 1 && - consumer.getPartitions().stream() - .anyMatch(tpi -> tpi.getPartition().get().equals(partition))) + consumer.getPartitions().size() == 1 && + consumer.getPartitions().stream() + .anyMatch(tpi -> tpi.getPartition().get().equals(partition))) .findFirst().orElse(null), Objects::nonNull); } @@ -778,10 +778,6 @@ public class TbRuleEngineQueueConsumerManagerTest { return false; } - public Set getPartitions() { - return partitions; - } - public void setUpTestMsg() { testMsg = TbMsg.newMsg() .type(TbMsgType.POST_TELEMETRY_REQUEST) @@ -790,6 +786,7 @@ public class TbRuleEngineQueueConsumerManagerTest { .data("{}") .build(); } + } } diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java index 1106fad5b6..9bd9bb2e7a 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java @@ -43,6 +43,7 @@ import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; import org.thingsboard.server.queue.TbQueueConsumer; import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.consumer.TbQueueConsumerTask.ConsumerKey; import org.thingsboard.server.queue.discovery.QueueKey; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory; @@ -191,6 +192,7 @@ public class TbRuleEngineStrategyTest { queue.setProcessingStrategy(processingStrategy); QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, queue); + ConsumerKey consumerKey = new ConsumerKey(queueKey, null); var consumerManager = TbRuleEngineQueueConsumerManager.create() .ctx(ruleEngineConsumerContext) .queueKey(queueKey) @@ -238,7 +240,7 @@ public class TbRuleEngineStrategyTest { .map(this::toProto) .toList(); - consumerManager.processMsgs(protoMsgs, consumer, queueKey, queue); + consumerManager.processMsgs(protoMsgs, consumer, consumerKey, queue); processingData.forEach(data -> { verify(actorContext, times(data.attempts)).tell(argThat(msg -> diff --git a/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java new file mode 100644 index 0000000000..f12a8d3c5d --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java @@ -0,0 +1,83 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.resource; + +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.GeneralFileDescriptor; +import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; +import org.thingsboard.server.common.data.TbResourceInfo; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +@DaoSqlTest +public class DefaultResourceDataCacheTest extends AbstractControllerTest { + + @MockitoSpyBean + private ResourceService resourceService; + @Autowired + private TbResourceService tbResourceService; + @MockitoSpyBean + private TbResourceDataCache resourceDataCache; + + @Test + public void testGetCachedResourceData() throws Exception { + loginTenantAdmin(); + + TbResource resource = new TbResource(); + resource.setTenantId(tenantId); + resource.setTitle("File for AI request"); + resource.setResourceType(ResourceType.GENERAL); + resource.setFileName("myTestJson.json"); + GeneralFileDescriptor descriptor = new GeneralFileDescriptor("application/json"); + resource.setDescriptorValue(descriptor); + byte[] data = "This is a test prompt for AI request.".getBytes(); + resource.setData(data); + TbResourceInfo savedResource = tbResourceService.save(resource); + verify(resourceDataCache, timeout(2000).times(1)).evictResourceData(tenantId, savedResource.getId()); + + TbResourceDataInfo cachedData = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); + assertThat(cachedData.getData()).isEqualTo(data); + assertThat(JacksonUtil.treeToValue(cachedData.getDescriptor(), GeneralFileDescriptor.class)).isEqualTo(descriptor); + verify(resourceService).getResourceDataInfo(tenantId, savedResource.getId()); + + // retrieve resource data second time + clearInvocations(resourceService); + TbResourceDataInfo cachedData2 = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); + assertThat(cachedData2.getData()).isEqualTo(data); + verifyNoMoreInteractions(resourceService); + + // delete resource, check cache + TbResource resourceById = resourceService.findResourceById(tenantId, savedResource.getId()); + tbResourceService.delete(resourceById, true, null); + verify(resourceDataCache, timeout(2000).times(2)).evictResourceData(tenantId, savedResource.getId()); + TbResourceDataInfo cachedDataAfterDeletion = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); + assertThat(cachedDataAfterDeletion).isEqualTo(null); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java b/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java index fe416eacd5..a146730465 100644 --- a/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.resource.sql; import com.datastax.oss.driver.api.core.uuid.Uuids; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -24,8 +25,10 @@ import org.junit.Test; import org.junit.jupiter.api.Assertions; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.ai.TbAiNode; +import org.thingsboard.rule.engine.ai.TbAiNodeConfiguration; +import org.thingsboard.rule.engine.ai.TbResponseFormat; import org.thingsboard.server.common.data.Dashboard; -import org.thingsboard.server.common.data.DashboardInfo; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; @@ -37,26 +40,40 @@ import org.thingsboard.server.common.data.TbResourceInfoFilter; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +import org.thingsboard.server.common.data.debug.DebugSettings; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; -import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.service.resource.TbResourceService; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; import java.util.Collections; import java.util.List; +import java.util.Set; +import java.util.function.Function; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -135,6 +152,10 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest { private WidgetTypeService widgetTypeService; @Autowired private DashboardService dashboardService; + @Autowired + private RuleChainService ruleChainService; + @Autowired + private AiModelService aiModelService; private Tenant savedTenant; private User tenantAdmin; @@ -453,11 +474,9 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest { Assert.assertFalse(result.getReferences().isEmpty()); Assert.assertEquals(1, result.getReferences().size()); - WidgetTypeInfo widgetTypeInfo = (WidgetTypeInfo) result.getReferences().get(EntityType.WIDGET_TYPE.name()).get(0); - WidgetTypeInfo foundWidgetTypeInfo = new WidgetTypeInfo(foundWidgetType); + EntityInfo widgetTypeInfo = (EntityInfo) result.getReferences().get(EntityType.WIDGET_TYPE.name()).get(0); Assert.assertNotNull(widgetTypeInfo); - Assert.assertNotNull(foundWidgetTypeInfo); - Assert.assertEquals(widgetTypeInfo, foundWidgetTypeInfo); + Assert.assertEquals(widgetTypeInfo, new EntityInfo(foundWidgetType.getId(), foundWidgetType.getName())); TbResourceInfo foundResourceInfo = resourceService.findResourceInfoById(savedTenant.getId(), savedResource.getId()); Assert.assertNotNull(foundResource); @@ -546,11 +565,9 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest { Assert.assertNotNull(result.getReferences()); Assert.assertEquals(1, result.getReferences().size()); - DashboardInfo dashboardInfo = (DashboardInfo) result.getReferences().get(EntityType.DASHBOARD.name()).get(0); - DashboardInfo foundDashboardInfo = dashboardService.findDashboardInfoById(savedTenant.getId(), savedDashboard.getId()); + EntityInfo dashboardInfo = (EntityInfo) result.getReferences().get(EntityType.DASHBOARD.name()).get(0); Assert.assertNotNull(dashboardInfo); - Assert.assertNotNull(foundDashboardInfo); - Assert.assertEquals(foundDashboardInfo, dashboardInfo); + Assert.assertEquals(new EntityInfo(savedDashboard.getId(), savedDashboard.getName()), dashboardInfo); foundResource = resourceService.findResourceById(savedTenant.getId(), savedResource.getId()); Assert.assertNotNull(foundResource); @@ -598,6 +615,94 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest { Assert.assertNull(foundResource); } + @Test + public void testShouldNotDeleteResourceIfUsedInAiNode() throws Exception { + TbResource resource = new TbResource(); + resource.setResourceType(ResourceType.GENERAL); + resource.setTitle("My resource"); + resource.setFileName("test.json"); + resource.setTenantId(savedTenant.getId()); + resource.setData("".getBytes()); + TbResourceInfo savedResource = tbResourceService.save(resource); + RuleChainMetaData ruleChain = createRuleChainReferringResource(savedResource.getId()); + + TbResourceDeleteResult result = tbResourceService.delete(savedResource, false, null); + assertThat(result).isNotNull(); + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getReferences()).isNotEmpty().hasSize(1); + EntityInfo entityInfo = (EntityInfo) result.getReferences().get(EntityType.RULE_CHAIN.name()).get(0); + assertThat(entityInfo).isEqualTo(new EntityInfo(ruleChain.getRuleChainId(), "Test")); + + TbResource foundResource = resourceService.findResourceById(savedTenant.getId(), savedResource.getId()); + assertThat(foundResource).isNotNull(); + + // force delete + TbResourceDeleteResult deleteResult = tbResourceService.delete(savedResource, true, null); + assertThat(deleteResult).isNotNull(); + assertThat(deleteResult.isSuccess()).isTrue(); + + TbResource resourceAfterDeletion = resourceService.findResourceById(savedTenant.getId(), savedResource.getId()); + assertThat(resourceAfterDeletion).isNull(); + } + + private RuleChainMetaData createRuleChainReferringResource(TbResourceId resourceId) { + AiModel model = constructValidOpenAiModel("Test model"); + AiModel saved = aiModelService.save(model); + + RuleChain ruleChain = new RuleChain(); + ruleChain.setTenantId(tenantId); + ruleChain.setName("Test"); + ruleChain.setType(RuleChainType.CORE); + ruleChain.setDebugMode(true); + ruleChain.setConfiguration(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); + ruleChain = ruleChainService.saveRuleChain(ruleChain); + RuleChainId ruleChainId = ruleChain.getId(); + + RuleChainMetaData metaData = new RuleChainMetaData(); + metaData.setRuleChainId(ruleChainId); + + RuleNode aiNode = new RuleNode(); + aiNode.setName("Ai request"); + aiNode.setType(org.thingsboard.rule.engine.ai.TbAiNode.class.getName()); + aiNode.setConfigurationVersion(TbAiNode.class.getAnnotation(org.thingsboard.rule.engine.api.RuleNode.class).version()); + aiNode.setDebugSettings(DebugSettings.all()); + TbAiNodeConfiguration configuration = new TbAiNodeConfiguration(); + configuration.setResourceIds(Set.of(resourceId.getId())); + configuration.setModelId(saved.getId()); + configuration.setResponseFormat(new TbResponseFormat.TbJsonResponseFormat()); + configuration.setTimeoutSeconds(1); + configuration.setUserPrompt("What is temp"); + aiNode.setConfiguration(JacksonUtil.valueToTree(configuration)); + + metaData.setNodes(Arrays.asList(aiNode)); + metaData.setFirstNodeIndex(0); + ruleChainService.saveRuleChainMetaData(tenantId, metaData, Function.identity()); + return ruleChainService.loadRuleChainMetaData(tenantId, ruleChainId); + } + + private AiModel constructValidOpenAiModel(String name) { + var modelConfig = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL) + .apiKey("test-api-key") + .build()) + .modelId("gpt-4o") + .temperature(0.5) + .topP(0.3) + .frequencyPenalty(0.1) + .presencePenalty(0.2) + .maxOutputTokens(1000) + .timeoutSeconds(60) + .maxRetries(2) + .build(); + + return AiModel.builder() + .tenantId(tenantId) + .name(name) + .configuration(modelConfig) + .build(); + } + @Test public void testFindTenantResourcesByTenantId() throws Exception { loginSysAdmin(); diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java index 3eeab8b7d9..440d87d768 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java @@ -125,7 +125,7 @@ public class JwtTokenFactoryTest { public void testCreateAndParsePreVerificationJwtToken() { SecurityUser securityUser = createSecurityUser(); int tokenLifetime = (int) TimeUnit.MINUTES.toSeconds(30); - JwtToken preVerificationToken = tokenFactory.createPreVerificationToken(securityUser, tokenLifetime); + JwtToken preVerificationToken = tokenFactory.createMfaToken(securityUser, Authority.PRE_VERIFICATION_TOKEN, tokenLifetime); checkExpirationTime(preVerificationToken, tokenLifetime); SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(preVerificationToken.getToken()); diff --git a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java index 2db78871d4..73f67739f3 100644 --- a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java @@ -244,7 +244,7 @@ public class VersionControlTest extends AbstractControllerTest { DeviceProfile deviceProfile = createDeviceProfile(null, null, "Device profile v1.0"); OtaPackage firmware = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.FIRMWARE); OtaPackage software = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.SOFTWARE); - Device device = createDevice(null, deviceProfile.getId(), "Device v1.0", "test1", newDevice -> { + Device device = createDevice(deviceProfile.getId(), "Device v1.0", "test1", newDevice -> { newDevice.setFirmwareId(firmware.getId()); newDevice.setSoftwareId(software.getId()); }); @@ -267,7 +267,7 @@ public class VersionControlTest extends AbstractControllerTest { createVersion("profiles", EntityType.DEVICE_PROFILE); OtaPackage firmware = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.FIRMWARE); OtaPackage software = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.SOFTWARE); - Device device = createDevice(null, deviceProfile.getId(), "Device of tenant 1", "test1", newDevice -> { + Device device = createDevice(deviceProfile.getId(), "Device of tenant 1", "test1", newDevice -> { newDevice.setFirmwareId(firmware.getId()); newDevice.setSoftwareId(software.getId()); }); @@ -528,7 +528,7 @@ public class VersionControlTest extends AbstractControllerTest { @Test public void testVcWithRelations_betweenTenants() throws Exception { Asset asset = createAsset(null, null, "Asset 1"); - Device device = createDevice(null, null, "Device 1", "test1"); + Device device = createDevice("Device 1", "test1"); EntityRelation relation = createRelation(asset.getId(), device.getId()); String versionId = createVersion("assets and devices", EntityType.ASSET, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE); @@ -554,11 +554,11 @@ public class VersionControlTest extends AbstractControllerTest { @Test public void testVcWithRelations_sameTenant() throws Exception { Asset asset = createAsset(null, null, "Asset 1"); - Device device1 = createDevice(null, null, "Device 1", "test1"); + Device device1 = createDevice("Device 1", "test1"); EntityRelation relation1 = createRelation(device1.getId(), asset.getId()); String versionId = createVersion("assets", EntityType.ASSET); - Device device2 = createDevice(null, null, "Device 2", "test2"); + Device device2 = createDevice("Device 2", "test2"); EntityRelation relation2 = createRelation(device2.getId(), asset.getId()); List relations = findRelationsByTo(asset.getId()); assertThat(relations).contains(relation1, relation2); @@ -591,7 +591,7 @@ public class VersionControlTest extends AbstractControllerTest { @Test public void testVcWithCalculatedFields_betweenTenants() throws Exception { Asset asset = createAsset(null, null, "Asset 1"); - Device device = createDevice(null, null, "Device 1", "test1"); + Device device = createDevice("Device 1", "test1"); CalculatedField calculatedField = createCalculatedField("CalculatedField1", device.getId(), asset.getId()); String versionId = createVersion("calculated fields of asset and device", EntityType.ASSET, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE); @@ -617,7 +617,7 @@ public class VersionControlTest extends AbstractControllerTest { @Test public void testVcWithReferencedCalculatedFields_betweenTenants() throws Exception { Asset asset = createAsset(null, null, "Asset 1"); - Device device = createDevice(null, null, "Device 1", "test1"); + Device device = createDevice("Device 1", "test1"); CalculatedField deviceCalculatedField = createCalculatedField("CalculatedField1", device.getId(), asset.getId()); CalculatedField assetCalculatedField = createCalculatedField("CalculatedField2", asset.getId(), device.getId()); String versionId = createVersion("calculated fields of asset and device", EntityType.ASSET, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE); @@ -911,9 +911,8 @@ public class VersionControlTest extends AbstractControllerTest { login(tenantAdmin2.getEmail(), tenantAdmin2.getEmail()); } - private Device createDevice(CustomerId customerId, DeviceProfileId deviceProfileId, String name, String accessToken, Consumer... modifiers) { + private Device createDevice(DeviceProfileId deviceProfileId, String name, String accessToken, Consumer... modifiers) { Device device = new Device(); - device.setCustomerId(customerId); device.setName(name); device.setLabel("lbl"); device.setDeviceProfileId(deviceProfileId); diff --git a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java index dbfca2e9f1..65633c6bd9 100644 --- a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java +++ b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java @@ -37,9 +37,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -/** - * @author Andrew Shvayka - */ @TestPropertySource(properties = { "transport.http.enabled=true", "transport.http.max_payload_size=/api/v1/*/rpc/**=10000;/api/v1/**=20000" diff --git a/application/src/test/java/org/thingsboard/server/system/BaseRestApiLimitsTest.java b/application/src/test/java/org/thingsboard/server/system/BaseRestApiLimitsTest.java index 84a366b139..fea9d5086c 100644 --- a/application/src/test/java/org/thingsboard/server/system/BaseRestApiLimitsTest.java +++ b/application/src/test/java/org/thingsboard/server/system/BaseRestApiLimitsTest.java @@ -45,10 +45,6 @@ import java.util.concurrent.TimeoutException; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -/** - * @author Illia Barkov - */ - @Slf4j public abstract class BaseRestApiLimitsTest extends AbstractControllerTest { diff --git a/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java b/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java index 6ff57a7ee0..f976156a86 100644 --- a/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java +++ b/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java @@ -29,7 +29,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; - @Slf4j public class RestTemplateConvertersTest { diff --git a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java new file mode 100644 index 0000000000..3c65f19d65 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java @@ -0,0 +1,410 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.system; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.WidgetTypeId; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.service.install.InstallScripts; +import org.thingsboard.server.service.system.SystemPatchApplier; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class SystemPatchApplierTest { + + @Mock + private JdbcTemplate jdbcTemplate; + + @Mock + private InstallScripts installScripts; + + @Mock + private WidgetTypeService widgetTypeService; + + @InjectMocks + private SystemPatchApplier reconciler; + + @TempDir + Path tempDir; + + @ParameterizedTest(name = "Parse version {0} should return major={1}, minor={2}, patch={3}") + @CsvSource({ + "4.2.1, 4, 2, 1, 0", + "4.2.0, 4, 2, 0, 0", + "4.2, 4, 2, 0, 0", + "4.0.1.2, 4, 0, 1, 2", + "4, 4, 0, 0, 0", + "1.0.5.7, 1, 0, 5, 7", + "10.20.30.40, 10, 20, 30, 40", + "0.0.1, 0, 0, 1, 0" + }) + void testParseVersion(String versionString, int expectedMajor, int expectedMinor, int expectedMaintenance, int expectedPatch) { + SystemPatchApplier.VersionInfo version = ReflectionTestUtils.invokeMethod(reconciler, "parseVersion", versionString); + + assertNotNull(version, "Version should not be null for: " + versionString); + assertEquals(expectedMajor, version.major(), "Major version mismatch"); + assertEquals(expectedMinor, version.minor(), "Minor version mismatch"); + assertEquals(expectedMaintenance, version.maintenance(), "Maintenance version mismatch"); + assertEquals(expectedPatch, version.patch(), "Patch version mismatch"); + } + + @ParameterizedTest(name = "Parse invalid version: {0}") + @CsvSource({ + "invalid", + "a.b.c", + "1.2.y.x", + "''", + "1.x.3" + }) + void testParseInvalidVersion(String invalidVersion) { + SystemPatchApplier.VersionInfo version = ReflectionTestUtils.invokeMethod(reconciler, "parseVersion", invalidVersion); + assertNull(version, "Version should be null for invalid input: " + invalidVersion); + } + + @Test + void whenLockIsNotAcquired_thenAcquiredIsSuccess() { + when(jdbcTemplate.queryForObject(anyString(), eq(Boolean.class), anyLong())).thenReturn(true); + + Boolean acquired = ReflectionTestUtils.invokeMethod(reconciler, "acquireAdvisoryLock"); + + assertEquals(Boolean.TRUE, acquired); + verify(jdbcTemplate).queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong()); + } + + @Test + void whenLockIsAlreadyAcquired_thenAcquiredIsFailed() { + when(jdbcTemplate.queryForObject(anyString(), eq(Boolean.class), anyLong())).thenReturn(false); + + Boolean acquired = ReflectionTestUtils.invokeMethod(reconciler, "acquireAdvisoryLock"); + + assertNotEquals(Boolean.TRUE, acquired); + } + + @Test + void testReleaseAdvisoryLock() { + when(jdbcTemplate.queryForObject(anyString(), eq(Boolean.class), anyLong())) + .thenReturn(true); + + ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); + + verify(jdbcTemplate).queryForObject( + contains("pg_advisory_unlock"), eq(Boolean.class), anyLong()); + } + + @Test + void whenWidgetNotFound_thenThrowException() throws Exception { + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + WidgetTypeDetails testWidget = createTestWidgetType("test_widget", "Test Widget"); + String json = JacksonUtil.toString(testWidget); + assertNotNull(json); + Files.writeString(widgetTypesDir.resolve("test_widget.json"), json); + + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")).thenReturn(null); + + assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes")); + } + + @Test + void whenDescriptorChanged_thenUpdateTheExistingWidget() throws Exception { + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + WidgetTypeDetails fileWidget = createTestWidgetType("test_widget", "Test Widget"); + fileWidget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":2}")); + String json = JacksonUtil.toString(fileWidget); + assertNotNull(json); + Files.writeString(widgetTypesDir.resolve("test_widget.json"), json); + + WidgetTypeDetails existingWidget = createTestWidgetType("test_widget", "Test Widget"); + existingWidget.setId(new WidgetTypeId(UUID.randomUUID())); + existingWidget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}")); + + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) + .thenReturn(existingWidget); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + + assertEquals(1, updated); + verify(widgetTypeService).saveWidgetType(argThat(w -> + w.getDescriptor().get("version").asInt() == 2 + )); + } + + @Test + void whenNameChanged_thenUpdateTheExistingWidget() throws Exception { + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + WidgetTypeDetails fileWidget = createTestWidgetType("test_widget", "New Name"); + String json = JacksonUtil.toString(fileWidget); + assertNotNull(json); + Files.writeString(widgetTypesDir.resolve("test_widget.json"), json); + + WidgetTypeDetails existingWidget = createTestWidgetType("test_widget", "Old Name"); + existingWidget.setId(new WidgetTypeId(UUID.randomUUID())); + + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) + .thenReturn(existingWidget); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + + assertEquals(1, updated); + verify(widgetTypeService).saveWidgetType(argThat(w -> "New Name".equals(w.getName()))); + } + + @Test + void whenNothingChanged_thenSkipTheUpdateOfTheExistingWidget() throws Exception { + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + WidgetTypeDetails fileWidget = createTestWidgetType("test_widget", "Test Widget"); + String json = JacksonUtil.toString(fileWidget); + assertNotNull(json); + Files.writeString(widgetTypesDir.resolve("test_widget.json"), json); + + WidgetTypeDetails existingWidget = createTestWidgetType("test_widget", "Test Widget"); + existingWidget.setId(new WidgetTypeId(UUID.randomUUID())); + + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) + .thenReturn(existingWidget); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + + assertEquals(0, updated); + verify(widgetTypeService, never()).saveWidgetType(any()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideDescriptorComparisonTestCases") + void testIfDescriptorsAreEqual(String testName, JsonNode desc1, JsonNode desc2, boolean expectedEqual) { + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isDescriptorEqual", desc1, desc2); + assertEquals(expectedEqual, result, testName); + } + + @Test + void whenDescriptorChanged_thenReturnWidgetTypeChanged() { + WidgetTypeDetails existing = createTestWidgetType("test", "Test"); + existing.setDescriptor(JacksonUtil.toJsonNode("{\"version\":1}")); + + WidgetTypeDetails file = createTestWidgetType("test", "Test"); + file.setDescriptor(JacksonUtil.toJsonNode("{\"version\":2}")); + + boolean result = Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(reconciler, "isWidgetTypeChanged", existing, file)); + assertTrue(result); + } + + @Test + void whenNameChanged_thenReturnWidgetTypeChanged() { + WidgetTypeDetails existing = createTestWidgetType("test", "Old Name"); + WidgetTypeDetails file = createTestWidgetType("test", "New Name"); + + boolean result = Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(reconciler, "isWidgetTypeChanged", existing, file)); + assertTrue(result); + } + + @Test + void whenDescriptionChanged_thenReturnWidgetTypeChanged() { + WidgetTypeDetails existing = createTestWidgetType("test", "Test"); + existing.setDescription("Old description"); + + WidgetTypeDetails file = createTestWidgetType("test", "Test"); + file.setDescription("New description"); + + boolean result = Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(reconciler, "isWidgetTypeChanged", existing, file)); + assertTrue(result); + } + + @Test + void whenWidgetTypeAreIdentical_thenNoUpdateIsPerformed() { + WidgetTypeDetails existing = createTestWidgetType("test", "Test"); + WidgetTypeDetails file = createTestWidgetType("test", "Test"); + + boolean result = Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(reconciler, "isWidgetTypeChanged", existing, file)); + assertFalse(result); + } + + @Test + void whenLockIsHeldByOneThread_thenSecondThreadCannotAcquireLock() throws Exception { + CountDownLatch lockAcquired = new CountDownLatch(1); + CountDownLatch startSecondThread = new CountDownLatch(1); + CountDownLatch testComplete = new CountDownLatch(1); + + AtomicBoolean firstThreadAcquiredLock = new AtomicBoolean(false); + AtomicBoolean secondThreadAcquiredLock = new AtomicBoolean(false); + AtomicBoolean firstThreadSavedWidget = new AtomicBoolean(false); + AtomicBoolean secondThreadSavedWidget = new AtomicBoolean(false); + + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + WidgetTypeDetails fileWidget = createTestWidgetType("test_widget", "Test Widget"); + fileWidget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":2}")); + String toString = JacksonUtil.toCanonicalString(fileWidget); + assertNotNull(toString); + Files.writeString(widgetTypesDir.resolve("test_widget.json"), toString); + + WidgetTypeDetails existingWidget = createTestWidgetType("test_widget", "Test Widget"); + existingWidget.setId(new WidgetTypeId(UUID.randomUUID())); + existingWidget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}")); + + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")).thenReturn(existingWidget); + + when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())) + .thenReturn(true) + .thenReturn(false); + + when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())) + .thenReturn(true); + + // The first thread-acquires lock and performs update + Thread firstThread = new Thread(() -> { + try { + Boolean acquired = ReflectionTestUtils.invokeMethod(reconciler, "acquireAdvisoryLock"); + firstThreadAcquiredLock.set(Boolean.TRUE.equals(acquired)); + + if (firstThreadAcquiredLock.get()) { + lockAcquired.countDown(); + startSecondThread.await(5, TimeUnit.SECONDS); + + // Simulate work while holding lock + Thread.sleep(100); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + firstThreadSavedWidget.set(updated != null && updated > 0); + + ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); + } + } catch (Exception ignored) { + } finally { + testComplete.countDown(); + } + }); + + // Second thread - attempts to acquire lock but fails + Thread secondThread = new Thread(() -> { + try { + lockAcquired.await(5, TimeUnit.SECONDS); + startSecondThread.countDown(); + + Boolean acquired = ReflectionTestUtils.invokeMethod(reconciler, "acquireAdvisoryLock"); + secondThreadAcquiredLock.set(Boolean.TRUE.equals(acquired)); + + if (secondThreadAcquiredLock.get()) { + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + secondThreadSavedWidget.set(updated != null && updated > 0); + + ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); + } + } catch (Exception ignored) {} + }); + + firstThread.start(); + secondThread.start(); + + assertTrue(testComplete.await(10, TimeUnit.SECONDS), "Test should complete within timeout"); + firstThread.join(1000); + secondThread.join(1000); + + assertTrue(firstThreadAcquiredLock.get(), "First thread should acquire lock"); + assertFalse(secondThreadAcquiredLock.get(), "Second thread should NOT acquire lock"); + assertTrue(firstThreadSavedWidget.get(), "First thread should save widget"); + assertFalse(secondThreadSavedWidget.get(), "Second thread should NOT save widget"); + + verify(widgetTypeService, times(1)).saveWidgetType(any()); + } + + private static Stream provideDescriptorComparisonTestCases() { + return Stream.of( + Arguments.of("Both null", null, null, true), + Arguments.of("First null", null, JacksonUtil.newObjectNode(), false), + Arguments.of("Second null", JacksonUtil.newObjectNode(), null, false), + Arguments.of("Same content", + JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"), + JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"), + true), + Arguments.of("Different content", + JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"), + JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":2}"), + false), + Arguments.of("Different key order but same content", + JacksonUtil.toJsonNode("{\"version\":1,\"type\":\"latest\"}"), + JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"), + true), + Arguments.of("Empty objects", + JacksonUtil.toJsonNode("{}"), + JacksonUtil.toJsonNode("{}"), + true) + ); + } + + private WidgetTypeDetails createTestWidgetType(String fqn, String name) { + WidgetTypeDetails widget = new WidgetTypeDetails(); + widget.setFqn(fqn); + widget.setName(name); + widget.setDescription("Test description"); + widget.setTenantId(TenantId.SYS_TENANT_ID); + widget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\"}")); + return widget; + } + +} diff --git a/application/src/test/java/org/thingsboard/server/system/sql/DeviceApiSqlTest.java b/application/src/test/java/org/thingsboard/server/system/sql/DeviceApiSqlTest.java index 74f3cbafd9..27e5237fd9 100644 --- a/application/src/test/java/org/thingsboard/server/system/sql/DeviceApiSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/system/sql/DeviceApiSqlTest.java @@ -18,9 +18,6 @@ package org.thingsboard.server.system.sql; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.system.BaseHttpDeviceApiTest; -/** - * Created by Valerii Sosliuk on 6/27/2017. - */ @DaoSqlTest public class DeviceApiSqlTest extends BaseHttpDeviceApiTest { } diff --git a/application/src/test/java/org/thingsboard/server/system/sql/RestApiLimitsSqlTest.java b/application/src/test/java/org/thingsboard/server/system/sql/RestApiLimitsSqlTest.java index 56c26fb3d3..932fb32328 100644 --- a/application/src/test/java/org/thingsboard/server/system/sql/RestApiLimitsSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/system/sql/RestApiLimitsSqlTest.java @@ -18,7 +18,6 @@ package org.thingsboard.server.system.sql; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.system.BaseRestApiLimitsTest; - @DaoSqlTest public class RestApiLimitsSqlTest extends BaseRestApiLimitsTest { } diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/security/AbstractCoapSecurityIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/security/AbstractCoapSecurityIntegrationTest.java index 72f8cc5b4f..839b8c7cad 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/security/AbstractCoapSecurityIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/security/AbstractCoapSecurityIntegrationTest.java @@ -64,6 +64,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. "coap.server.enabled=true", "coap.dtls.enabled=true", "coap.dtls.credentials.pem.cert_file=coap/credentials/server/cert.pem", + "coap.dtls.x509.skip_validity_check_for_client_cert=true", "device.connectivity.coaps.enabled=true", "service.integrations.supported=ALL", "transport.coap.enabled=true", diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java index ddf8bca43f..a7d4cea8d4 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java @@ -21,6 +21,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.IOUtils; +import org.awaitility.core.ConditionTimeoutException; import org.eclipse.leshan.client.LeshanClient; import org.eclipse.leshan.client.object.Security; import org.eclipse.leshan.client.servers.LwM2mServer; @@ -84,6 +85,7 @@ import org.thingsboard.server.transport.lwm2m.server.client.ResourceUpdateResult import org.thingsboard.server.transport.lwm2m.server.uplink.DefaultLwM2mUplinkMsgHandler; import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler; +import java.io.IOException; import java.net.ServerSocket; import java.util.ArrayList; import java.util.Arrays; @@ -94,6 +96,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import static org.awaitility.Awaitility.await; import static org.eclipse.leshan.client.object.Security.noSec; @@ -118,13 +121,14 @@ import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClient import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_UPDATE_SUCCESS; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.lwm2mClientResources; import static org.thingsboard.server.transport.lwm2m.ota.AbstractOtaLwM2MIntegrationTest.CLIENT_LWM2M_SETTINGS_19; -@TestPropertySource(properties = { - "transport.lwm2m.enabled=true", -}) @Slf4j @DaoSqlTest +@TestPropertySource(properties = { + "transport.lwm2m.enabled=true" +}) public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportIntegrationTest { @SpyBean @@ -145,9 +149,6 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte public static final String host = "localhost"; public static final String hostBs = "localhost"; public static final Integer shortServerId = 123; - public static final Integer shortServerIdBs0 = 0; - public static final int serverId = 1; - public static final int serverIdBs = 0; public static final String COAP = "coap://"; public static final String COAPS = "coaps://"; @@ -306,7 +307,7 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte protected final Set expectedStatusesRegistrationBsSuccess = new HashSet<>(Arrays.asList(ON_BOOTSTRAP_STARTED, ON_BOOTSTRAP_SUCCESS, ON_REGISTRATION_STARTED, ON_REGISTRATION_SUCCESS)); protected ScheduledExecutorService executor; protected LwM2MTestClient lwM2MTestClient; - private String[] resources; + private String[] resources = lwm2mClientResources; protected String deviceId; protected boolean supportFormatOnly_SenMLJSON_SenMLCBOR = false; @@ -317,7 +318,7 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte @After public void after() throws Exception { - this.clientDestroy(true); + this.clientDestroy(); if (executor != null && !executor.isShutdown()) { executor.shutdownNow(); } @@ -548,7 +549,9 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte } public void setResources(String[] resources) { - this.resources = resources; + if (this.resources == null || !Arrays.equals(this.resources, resources)) { + this.resources = resources; + } } public void createNewClient(Security security, Security securityBs, boolean isRpc, @@ -564,7 +567,7 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte public void createNewClient(Security security, Security securityBs, boolean isRpc, String endpoint, Integer clientDtlsCidLength, boolean queueMode, String deviceIdStr, Integer value3_0_9) throws Exception { - this.clientDestroy(false); + this.clientDestroy(); lwM2MTestClient = new LwM2MTestClient(this.executor, endpoint, resources); try (ServerSocket socket = new ServerSocket(0)) { @@ -651,13 +654,30 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte } - private void clientDestroy(boolean isAfter) { + private void clientDestroy() { try { if (lwM2MTestClient != null && lwM2MTestClient.getLeshanClient() != null) { - if (isAfter) { - sendObserveCancelAllWithAwait(lwM2MTestClient.getDeviceIdStr()); - awaitDeleteDevice(lwM2MTestClient.getDeviceIdStr()); + boolean serverAlive = false; + for (int port = AbstractLwM2MIntegrationTest.port; port <= securityPortBs; port++) { + try (ServerSocket socket = new ServerSocket(port)) { + log.info("Port {} is free.", port); + } catch (IOException e) { + log.debug("Port {} is busy — CoAP server still active.", port); + serverAlive = true; + break; + } + } + if (serverAlive) { + try { + sendObserveCancelAllWithAwait(lwM2MTestClient.getDeviceIdStr()); + awaitDeleteDevice(lwM2MTestClient.getDeviceIdStr()); + } catch (Exception e) { + log.warn("Failed to cleanup LwM2M observations before destroy: {}", e.getMessage()); + } + } else { + log.info("No active CoAP server found on ports 5685–5688. Skipping observe cleanup."); } + lwM2MTestClient.destroy(); } } catch (Exception e) { @@ -705,10 +725,10 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte return bootstrap; } - private AbstractLwM2MBootstrapServerCredential getBootstrapServerCredentialNoSec(boolean isBootstrap) { + protected AbstractLwM2MBootstrapServerCredential getBootstrapServerCredentialNoSec(boolean isBootstrap) { AbstractLwM2MBootstrapServerCredential bootstrapServerCredential = new NoSecLwM2MBootstrapServerCredential(); bootstrapServerCredential.setServerPublicKey(""); - bootstrapServerCredential.setShortServerId(isBootstrap ? shortServerIdBs0 : shortServerId); + bootstrapServerCredential.setShortServerId(isBootstrap ? null : shortServerId); bootstrapServerCredential.setBootstrapServerIs(isBootstrap); bootstrapServerCredential.setHost(isBootstrap ? hostBs : host); bootstrapServerCredential.setPort(isBootstrap ? portBs : port); @@ -726,11 +746,19 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte return credentials; } - protected void awaitObserveReadAll(int cntObserve, String deviceIdStr) throws Exception { - await("ObserveReadAll: countObserve " + cntObserve) - .atMost(40, TimeUnit.SECONDS) - .until(() -> cntObserve == getCntObserveAll(deviceIdStr)); + + protected void awaitObserveReadAll(int cntObserve, String deviceIdStr) throws Exception { + try { + await("ObserveReadAll: countObserve " + cntObserve) + .atMost(40, TimeUnit.SECONDS) + .until(() -> cntObserve == getCntObserveAll(deviceIdStr)); + } catch (ConditionTimeoutException e) { + int current = getCntObserveAll(deviceIdStr); + log.error("Condition or device {} with alias 'ObserveReadAll: countObserve {}, but received {}", deviceIdStr, cntObserve, current); + throw e; + } } + protected void awaitDeleteDevice(String deviceIdStr) throws Exception { await("Delete device with id: " + deviceIdStr) .atMost(40, TimeUnit.SECONDS) @@ -741,6 +769,19 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte }); } + protected void updateRegAtLeastOnceAfterAction() { + long initialInvocationCount = countUpdateReg(); + AtomicLong newInvocationCount = new AtomicLong(initialInvocationCount); + log.trace("updateRegAtLeastOnceAfterAction: initialInvocationCount [{}]", initialInvocationCount); + await("Update Registration at-least-once after action") + .atMost(50, TimeUnit.SECONDS) + .until(() -> { + newInvocationCount.set(countUpdateReg()); + return newInvocationCount.get() > initialInvocationCount; + }); + log.trace("updateRegAtLeastOnceAfterAction: newInvocationCount [{}]", newInvocationCount.get()); + } + protected Integer getCntObserveAll(String deviceIdStr) throws Exception { String actualResult = sendObserveOK("ObserveReadAll", null, deviceIdStr); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/Lwm2mTestHelper.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/Lwm2mTestHelper.java index 6159400bb6..c5233b378b 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/Lwm2mTestHelper.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/Lwm2mTestHelper.java @@ -17,15 +17,13 @@ package org.thingsboard.server.transport.lwm2m; public class Lwm2mTestHelper { - public static final String[] lwm2mClientResources = new String[]{"3.xml", "5.xml", "6.xml", "9.xml", "19.xml", "3303.xml"}; + public static final String[] lwm2mClientResources = new String[]{"3-1_2.xml", "5.xml", "6.xml", "9.xml", "19.xml", "3303.xml"}; // Models public static final int BINARY_APP_DATA_CONTAINER = 19; public static final int TEMPERATURE_SENSOR = 3303; // Ids in Client - public static final int OBJECT_ID_0 = 0; - public static final int OBJECT_ID_1 = 1; public static final int OBJECT_INSTANCE_ID_0 = 0; public static final int OBJECT_INSTANCE_ID_1 = 1; public static final int OBJECT_INSTANCE_ID_2 = 2; diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java index 051a68c9ab..8e247a8814 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java @@ -16,6 +16,7 @@ package org.thingsboard.server.transport.lwm2m.client; import lombok.extern.slf4j.Slf4j; +import org.eclipse.leshan.client.LeshanClient; import org.eclipse.leshan.client.resource.BaseInstanceEnabler; import org.eclipse.leshan.client.servers.LwM2mServer; import org.eclipse.leshan.core.model.ObjectModel; @@ -32,6 +33,9 @@ import java.util.List; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicInteger; +import static org.thingsboard.server.dao.service.OtaPackageServiceTest.TARGET_FW_VERSION; +import static org.thingsboard.server.dao.service.OtaPackageServiceTest.TITLE; @Slf4j public class FwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { @@ -44,6 +48,12 @@ public class FwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { private final AtomicInteger updateResult = new AtomicInteger(0); + private LeshanClient leshanClient; + private String pkgNameDef = "firmware"; + private String pkgName; + private String pkgVersionDef = "1.0.0"; + private String pkgVersion; + @Override public ReadResponse read(LwM2mServer identity, int resourceId) { if (!identity.isSystem()) @@ -74,7 +84,7 @@ public class FwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { switch (resourceId) { case 2: - startUpdating(); + startUpdating(identity); return ExecuteResponse.success(); default: return super.execute(identity, resourceId, arguments); @@ -106,11 +116,13 @@ public class FwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { } private String getPkgName() { - return "firmware"; + this.pkgName = this.pkgName == null ? this.pkgNameDef : this.pkgName; + return this.pkgName; } private String getPkgVersion() { - return "1.0.0"; + this.pkgVersion = this.pkgVersion == null ? this.pkgVersionDef : this.pkgVersion; + return this.pkgVersion; } private int getFirmwareUpdateDeliveryMethod() { @@ -140,7 +152,7 @@ public class FwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { }, 100, TimeUnit.MILLISECONDS); } - private void startUpdating() { + private void startUpdating(LwM2mServer identity) { scheduler.schedule(() -> { try { state.set(3); @@ -148,9 +160,25 @@ public class FwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { Thread.sleep(100); updateResult.set(1); fireResourceChange(5); + this.pkgName = TITLE; + fireResourceChange(6); + this.pkgVersion = TARGET_FW_VERSION; + fireResourceChange(7); + if (this.leshanClient != null) { + log.info("Stop/reboot LwM2M client {}", this.leshanClient.getEndpoint(identity)); + this.leshanClient.stop(false); + log.info("Start after update fw LwM2M client {}", this.leshanClient.getEndpoint(identity)); + this.leshanClient.start(); + this.pkgName = this.pkgNameDef; + this.pkgVersion = this.pkgVersionDef; + } } catch (Exception e) { } }, 100, TimeUnit.MILLISECONDS); } + protected void setLeshanClient(LeshanClient leshanClient) { + this.leshanClient = leshanClient; + } + } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java index 2ae1432cac..d2543f84d7 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java @@ -68,6 +68,9 @@ import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandle import org.thingsboard.server.transport.lwm2m.utils.LwM2mValueConverterImpl; import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.HashMap; @@ -77,6 +80,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; 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; @@ -88,10 +92,7 @@ import static org.eclipse.leshan.core.LwM2mId.SECURITY; import static org.eclipse.leshan.core.LwM2mId.SERVER; import static org.eclipse.leshan.core.LwM2mId.SOFTWARE_MANAGEMENT; import static org.eclipse.leshan.core.node.codec.DefaultLwM2mEncoder.getDefaultPathEncoder; -import static org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest.serverId; -import static org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest.serverIdBs; import static org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest.shortServerId; -import static org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest.shortServerIdBs0; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.BINARY_APP_DATA_CONTAINER; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_BOOTSTRAP_FAILURE; @@ -141,7 +142,7 @@ public class LwM2MTestClient { private LwM2mTemperatureSensor lwM2mTemperatureSensor12; private String deviceIdStr; - public void init(Security security, Security securityBs, int port, boolean isRpc, + public void init(Security securityLwm2m, Security securityBs, int port, boolean isRpc, LwM2mUplinkMsgHandler defaultLwM2mUplinkMsgHandler, LwM2mClientContext clientContext, Integer cIdLength, boolean queueMode, boolean supportFormatOnly_SenMLJSON_SenMLCBOR, Integer value3_0_9) throws InvalidDDFFileException, IOException { @@ -149,55 +150,33 @@ public class LwM2MTestClient { this.defaultLwM2mUplinkMsgHandlerTest = defaultLwM2mUplinkMsgHandler; this.clientContext = clientContext; - List models = ObjectLoader.loadAllDefault(); - for (String resourceName : lwm2mClientResources) { - models.addAll(ObjectLoader.loadDdfFile(LwM2MTestClient.class.getClassLoader().getResourceAsStream("lwm2m/" + resourceName), resourceName)); + ObjectsInitializer initializer = createFreshInitializer(); + + // SECURITY + if (securityLwm2m != null && securityLwm2m.getId() != null) { + forceNullSecurityId(securityLwm2m); } - if (this.modelResources != null) { - List modelsRes = new ArrayList<>(); - for (String resourceName : this.modelResources) { - modelsRes.addAll(ObjectLoader.loadDdfFile(LwM2MTestClient.class.getClassLoader().getResourceAsStream("lwm2m/" + resourceName), resourceName)); - } - Set idsToRemove = new HashSet<>(); - for (ObjectModel model : modelsRes) { - idsToRemove.add(model.id); - } - models.removeIf(model -> idsToRemove.contains(model.id)); - models.addAll(modelsRes); + if (securityBs!= null && securityBs.getId() != null) { + forceNullSecurityId(securityBs); } - - LwM2mModel model = new StaticModel(models); - ObjectsInitializer initializer = new ObjectsInitializer(model); - if (securityBs != null && security != null) { - // SECURITY - security.setId(serverId); - securityBs.setId(serverIdBs); - LwM2mInstanceEnabler[] instances = new LwM2mInstanceEnabler[]{securityBs, security}; - initializer.setClassForObject(SECURITY, Security.class); - initializer.setInstancesForObject(SECURITY, instances); - // SERVER - Server lwm2mServer = new Server(shortServerId, TimeUnit.MINUTES.toSeconds(60)); - lwm2mServer.setId(serverId); - Server serverBs = new Server(shortServerIdBs0, TimeUnit.MINUTES.toSeconds(60)); - serverBs.setId(serverIdBs); - instances = new LwM2mInstanceEnabler[]{serverBs, lwm2mServer}; - initializer.setClassForObject(SERVER, Server.class); - initializer.setInstancesForObject(SERVER, instances); + if (securityBs != null && securityLwm2m != null) { + log.warn("Security Both: securityBs: [{}] and security Lwm2m [{}]", securityBs.getId(), securityLwm2m.getId()); + initializer.setInstancesForObject(SECURITY, securityBs, securityLwm2m); } else if (securityBs != null) { - // SECURITY + log.warn("Security BS only: securityBs: [{}] ", securityBs.getId()); initializer.setInstancesForObject(SECURITY, securityBs); - // SERVER - initializer.setClassForObject(SERVER, Server.class); - } else { + } else if (securityLwm2m != null){ // SECURITY - initializer.setInstancesForObject(SECURITY, security); - // SERVER - Server lwm2mServer = new Server(shortServerId, TimeUnit.MINUTES.toSeconds(60)); - lwm2mServer.setId(serverId); - initializer.setInstancesForObject(SERVER, lwm2mServer); + log.warn("Security Lwm2m only: security Lwm2m [{}]", securityLwm2m.getId()); + initializer.setInstancesForObject(SECURITY, securityLwm2m); } - + // SERVER + Server serverLwm2m = new Server(shortServerId, TimeUnit.MINUTES.toSeconds(60)); + initializer.setInstancesForObject(SERVER, serverLwm2m); + // DEVICE initializer.setInstancesForObject(DEVICE, lwM2MDevice = new SimpleLwM2MDevice(executor, value3_0_9)); + + // OTHER t initializer.setInstancesForObject(FIRMWARE, fwLwM2MDevice = new FwLwM2MDevice()); initializer.setInstancesForObject(SOFTWARE_MANAGEMENT, swLwM2MDevice = new SwLwM2MDevice()); initializer.setClassForObject(ACCESS_CONTROL, DummyInstanceEnabler.class); @@ -444,25 +423,43 @@ public class LwM2MTestClient { public void destroy() { if (leshanClient != null) { - leshanClient.destroy(true); - } - if (lwM2MDevice != null) { - lwM2MDevice.destroy(); - } - if (fwLwM2MDevice != null) { - fwLwM2MDevice.destroy(); - } - if (swLwM2MDevice != null) { - swLwM2MDevice.destroy(); - } - if (lwM2MBinaryAppDataContainer != null) { - lwM2MBinaryAppDataContainer.destroy(); + try { + leshanClient.destroy(true); + } catch (Exception e) { + log.warn("Failed to destroy Leshan client", e); + } finally { + leshanClient = null; + } } - if (lwM2MTemperatureSensor != null) { - lwM2MTemperatureSensor.destroy(); + + // ThingsBoard custom LwM2M objects + destroySafe(lwM2MDevice); + destroySafe(fwLwM2MDevice); + destroySafe(swLwM2MDevice); + destroySafe(lwM2MBinaryAppDataContainer); + destroySafe(lwM2MTemperatureSensor); + + lwM2MDevice = null; + fwLwM2MDevice = null; + swLwM2MDevice = null; + lwM2MBinaryAppDataContainer = null; + lwM2MTemperatureSensor = null; + } + + + private void destroySafe(Object obj) { + if (obj == null) return; + try { + Method destroy = obj.getClass().getMethod("destroy"); + destroy.invoke(obj); + } catch (NoSuchMethodException e) { + // не має destroy() — ігноруємо + } catch (Exception e) { + log.warn("Failed to destroy {}", obj.getClass().getSimpleName(), e); } } + public void start(boolean isStartLw) { if (leshanClient != null) { leshanClient.start(); @@ -470,6 +467,7 @@ public class LwM2MTestClient { this.awaitClientAfterStartConnectLw(); } lwM2mTemperatureSensor12.setLeshanClient(leshanClient); + fwLwM2MDevice.setLeshanClient(leshanClient); } } @@ -483,4 +481,59 @@ public class LwM2MTestClient { LwM2mClient lwM2MClient = this.clientContext.getClientByEndpoint(endpoint); Mockito.doAnswer(invocationOnMock -> null).when(defaultLwM2mUplinkMsgHandlerTest).initAttributes(lwM2MClient, true); } + + private ObjectsInitializer createFreshInitializer() { + List models = new ArrayList<>(ObjectLoader.loadAllDefault()); + for (String resourceName : lwm2mClientResources) { + try (InputStream in = LwM2MTestClient.class.getClassLoader() + .getResourceAsStream("lwm2m/" + resourceName)) { + models.addAll(ObjectLoader.loadDdfFile(in, resourceName)); + } catch (IOException | InvalidDDFFileException e) { + log.warn("Failed to load resource {}", resourceName, e); + } + } + if (this.modelResources != null) { + List modelsRes = new ArrayList<>(); + for (String resourceName : this.modelResources) { + try (InputStream in = LwM2MTestClient.class.getClassLoader() + .getResourceAsStream("lwm2m/" + resourceName)) { + modelsRes.addAll(ObjectLoader.loadDdfFile(in, resourceName)); + } catch (IOException | InvalidDDFFileException e) { + log.warn("Failed to load resource {}", resourceName, e); + } + } + Set idsToRemove = modelsRes.stream() + .map(m -> m.id) + .collect(Collectors.toSet()); + models.removeIf(m -> idsToRemove.contains(m.id)); + models.addAll(modelsRes); + } + LwM2mModel model = new StaticModel(models); + return new ObjectsInitializer(model); + } + + private void forceNullSecurityId(Security security) { + if (security == null) { + return; + } + try { + Field field = security.getClass().getDeclaredField("id"); + field.setAccessible(true); + field.set(security, null); + log.info("[forceNullSecurityId] Set id=null for {}", security); + } catch (NoSuchFieldException e) { + try { + //(SecurityObjectInstance) + Field field = security.getClass().getSuperclass().getDeclaredField("id"); + field.setAccessible(true); + field.set(security, null); + log.info("[forceNullSecurityId] Set id=null for {} (via superclass)", security); + } catch (Exception ex) { + log.error("[forceNullSecurityId] Field 'id' not found for {}", security.getClass(), ex); + } + } catch (Exception e) { + log.error("[forceNullSecurityId] Failed to set id=null for {}", security.getClass(), e); + } + } } + diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/AbstractOtaLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/AbstractOtaLwM2MIntegrationTest.java index a67f3cf00f..cff56694e5 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/AbstractOtaLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/AbstractOtaLwM2MIntegrationTest.java @@ -54,7 +54,6 @@ import static org.thingsboard.server.transport.lwm2m.server.ota.DefaultLwM2MOtaU @DaoSqlTest public abstract class AbstractOtaLwM2MIntegrationTest extends AbstractLwM2MIntegrationTest { - private final String[] RESOURCES_OTA = new String[]{"3.xml", "5.xml", "9.xml", "19.xml"}; protected static final String CLIENT_ENDPOINT_WITHOUT_FW_INFO = "WithoutFirmwareInfoDevice"; protected static final String CLIENT_ENDPOINT_OTA5 = "Ota5_Device"; protected static final String CLIENT_ENDPOINT_OTA9 = "Ota9_Device"; @@ -186,10 +185,6 @@ public abstract class AbstractOtaLwM2MIntegrationTest extends AbstractLwM2MInteg " \"attributeLwm2m\": {}\n" + " }"; - public AbstractOtaLwM2MIntegrationTest() { - setResources(this.RESOURCES_OTA); - } - protected OtaPackageInfo createFirmware(String version, DeviceProfileId deviceProfileId) throws Exception { String CHECKSUM = "4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a"; diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota5LwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota5LwM2MIntegrationTest.java index 3065255687..e4db6f70bf 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota5LwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota5LwM2MIntegrationTest.java @@ -45,6 +45,7 @@ import static org.thingsboard.server.common.data.ota.OtaPackageUpdateStatus.INIT import static org.thingsboard.server.common.data.ota.OtaPackageUpdateStatus.QUEUED; import static org.thingsboard.server.common.data.ota.OtaPackageUpdateStatus.UPDATED; import static org.thingsboard.server.common.data.ota.OtaPackageUpdateStatus.UPDATING; +import static org.thingsboard.server.dao.service.OtaPackageServiceTest.TARGET_FW_VERSION; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.BINARY_APP_DATA_CONTAINER; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_0; @@ -91,13 +92,14 @@ public class Ota5LwM2MIntegrationTest extends AbstractOtaLwM2MIntegrationTest { @Test public void testFirmwareUpdateByObject5_Ok() throws Exception { Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITH_PARAMS_OTA5, getBootstrapServerCredentialsNoSec(NONE)); - DeviceProfile deviceProfile = createLwm2mDeviceProfile("profileFor" + this.CLIENT_ENDPOINT_OTA5, transportConfiguration); - LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsNoSec(createNoSecClientCredentials(this.CLIENT_ENDPOINT_OTA5)); - final Device device = createLwm2mDevice(deviceCredentials, this.CLIENT_ENDPOINT_OTA5, deviceProfile.getId()); - createNewClient(SECURITY_NO_SEC, null, false, this.CLIENT_ENDPOINT_OTA5, device.getId().getId().toString()); + DeviceProfile deviceProfile = createLwm2mDeviceProfile("profileFor" + this.CLIENT_ENDPOINT_OTA5 + "Ok", transportConfiguration); + String endpoint = this.CLIENT_ENDPOINT_OTA5 + "Ok"; + LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsNoSec(createNoSecClientCredentials(endpoint)); + final Device device = createLwm2mDevice(deviceCredentials, endpoint, deviceProfile.getId()); + createNewClient(SECURITY_NO_SEC, null, false, endpoint, device.getId().getId().toString()); awaitObserveReadAll(5, device.getId().getId().toString()); - device.setFirmwareId(createFirmware("fw.v.1.5.0-update", deviceProfile.getId()).getId()); + device.setFirmwareId(createFirmware(TARGET_FW_VERSION, deviceProfile.getId()).getId()); final Device savedDevice = doPost("/api/device", device, Device.class); assertThat(savedDevice).as("saved device").isNotNull(); @@ -110,7 +112,6 @@ public class Ota5LwM2MIntegrationTest extends AbstractOtaLwM2MIntegrationTest { log.warn("Object5: Got the ts: {}", ts); } - /** * ObjectId = 19/65533/0 * { @@ -133,13 +134,14 @@ public class Ota5LwM2MIntegrationTest extends AbstractOtaLwM2MIntegrationTest { @Test public void testFirmwareUpdateByObject5WithObject19_Ok() throws Exception { Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration19(OBSERVE_ATTRIBUTES_WITH_PARAMS_OTA5_19, getBootstrapServerCredentialsNoSec(NONE)); - DeviceProfile deviceProfile = createLwm2mDeviceProfile("profileFor" + this.CLIENT_ENDPOINT_OTA5, transportConfiguration); - LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsNoSec(createNoSecClientCredentials(this.CLIENT_ENDPOINT_OTA5)); - final Device device = createLwm2mDevice(deviceCredentials, this.CLIENT_ENDPOINT_OTA5, deviceProfile.getId()); - createNewClient(SECURITY_NO_SEC, null, false, this.CLIENT_ENDPOINT_OTA5, device.getId().getId().toString()); + DeviceProfile deviceProfile = createLwm2mDeviceProfile("profileFor" + this.CLIENT_ENDPOINT_OTA5 + "19_Ok", transportConfiguration); + String endpoint = this.CLIENT_ENDPOINT_OTA5 + "19_Ok"; + LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsNoSec(createNoSecClientCredentials(endpoint)); + final Device device = createLwm2mDevice(deviceCredentials, endpoint, deviceProfile.getId()); + createNewClient(SECURITY_NO_SEC, null, false, endpoint, device.getId().getId().toString()); awaitObserveReadAll(6, device.getId().getId().toString()); - OtaPackageInfo otaPackageInfo = createFirmware("fw.v.1.5.0-update", deviceProfile.getId()); + OtaPackageInfo otaPackageInfo = createFirmware(TARGET_FW_VERSION, deviceProfile.getId()); device.setFirmwareId(otaPackageInfo.getId()); final Device savedDevice = doPost("/api/device", device, Device.class); @@ -154,6 +156,6 @@ public class Ota5LwM2MIntegrationTest extends AbstractOtaLwM2MIntegrationTest { String ver_Id_19 = lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(BINARY_APP_DATA_CONTAINER).version; String resourceIdVer = "/" + BINARY_APP_DATA_CONTAINER + "_" + ver_Id_19 + "/" + FW_INSTANCE_ID + "/" + RESOURCE_ID_0; resultReadOtaParams_19(resourceIdVer, otaPackageInfo); - log.warn("Object5: Got the ts: {}", ts); + log.warn("Object5 with Object19: Got the ts: {}", ts); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_0_Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_0_Test.java index 7e9efbbf75..f333fd012c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_0_Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_0_Test.java @@ -20,9 +20,8 @@ import org.thingsboard.server.dao.service.DaoSqlTest; @DaoSqlTest public abstract class AbstractRpcLwM2MIntegrationObserve_Ver_1_0_Test extends AbstractRpcLwM2MIntegrationTest{ - public AbstractRpcLwM2MIntegrationObserve_Ver_1_0_Test() { - String[] RESOURCES_RPC_VER_1_1 = new String[]{"3-1_0.xml", "5.xml", "6.xml", "9.xml", "19.xml"}; - setResources(RESOURCES_RPC_VER_1_1); + public AbstractRpcLwM2MIntegrationObserve_Ver_1_0_Test() throws Exception { + String[] RESOURCES_RPC_VER_1_0 = new String[]{"3-1_0.xml", "5.xml", "6.xml", "9.xml", "19.xml"}; + setResources(RESOURCES_RPC_VER_1_0); } } - diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_1_Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_1_Test.java index f21c857e8a..b39b3d1130 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_1_Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_1_Test.java @@ -20,7 +20,7 @@ import org.thingsboard.server.dao.service.DaoSqlTest; @DaoSqlTest public abstract class AbstractRpcLwM2MIntegrationObserve_Ver_1_1_Test extends AbstractRpcLwM2MIntegrationTest{ - public AbstractRpcLwM2MIntegrationObserve_Ver_1_1_Test() { + public AbstractRpcLwM2MIntegrationObserve_Ver_1_1_Test() throws Exception { String[] RESOURCES_RPC_VER_1_1 = new String[]{"3-1_1.xml", "5.xml", "6.xml", "9.xml", "19.xml"}; setResources(RESOURCES_RPC_VER_1_1); } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_2_Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_2_Test.java index 347d04a900..8c07379004 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_2_Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_2_Test.java @@ -20,9 +20,9 @@ import org.thingsboard.server.dao.service.DaoSqlTest; @DaoSqlTest public abstract class AbstractRpcLwM2MIntegrationObserve_Ver_1_2_Test extends AbstractRpcLwM2MIntegrationTest{ - public AbstractRpcLwM2MIntegrationObserve_Ver_1_2_Test() { - String[] RESOURCES_RPC_VER_1_1 = new String[]{"3.xml", "5.xml", "6.xml", "9.xml", "19.xml"}; - setResources(RESOURCES_RPC_VER_1_1); + public AbstractRpcLwM2MIntegrationObserve_Ver_1_2_Test() throws Exception { + String[] RESOURCES_RPC_VER_1_2 = new String[]{"3-1_2.xml", "5.xml", "6.xml", "9.xml", "19.xml"}; + setResources(RESOURCES_RPC_VER_1_2); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationTest.java index be85294f08..6c75f7161b 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationTest.java @@ -15,7 +15,9 @@ */ package org.thingsboard.server.transport.lwm2m.rpc; +import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.extern.slf4j.Slf4j; +import org.eclipse.leshan.core.ResponseCode; import org.eclipse.leshan.core.link.LinkParser; import org.eclipse.leshan.core.link.lwm2m.DefaultLwM2mLinkParser; import org.junit.Before; @@ -40,15 +42,17 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.function.Predicate; import static org.awaitility.Awaitility.await; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.eclipse.leshan.core.LwM2mId.ACCESS_CONTROL; import static org.eclipse.leshan.core.LwM2mId.DEVICE; import static org.eclipse.leshan.core.LwM2mId.FIRMWARE; +import static org.eclipse.leshan.core.LwM2mId.SECURITY; import static org.eclipse.leshan.core.LwM2mId.SERVER; import static org.eclipse.leshan.core.LwM2mId.SOFTWARE_MANAGEMENT; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.BINARY_APP_DATA_CONTAINER; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; -import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_ID_0; -import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_ID_1; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_0; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_1; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_12; @@ -102,10 +106,6 @@ public abstract class AbstractRpcLwM2MIntegrationTest extends AbstractLwM2MInteg @SpyBean protected LwM2mTransportServerHelper lwM2mTransportServerHelperTest; - public AbstractRpcLwM2MIntegrationTest() { - setResources(lwm2mClientResources); - } - @Before public void startInitRPC() throws Exception { if (this.getClass().getSimpleName().equals("RpcLwm2mIntegrationWriteCborTest")) { @@ -115,6 +115,10 @@ public abstract class AbstractRpcLwM2MIntegrationTest extends AbstractLwM2MInteg initRpc(0); } else if (this.getClass().getSimpleName().equals("RpcLwm2mIntegrationReadCollectedValueTest")) { initRpc(3303); + } else if (this.getClass().getSimpleName().equals("RpcLwm2mIntegrationInitReadCompositeAllTest")) { + initRpc(2); + }else if (this.getClass().getSimpleName().equals("RpcLwm2mIntegrationInitReadCompositeByObjectTest")) { + initRpc(3); } else { initRpc(1); } @@ -140,10 +144,10 @@ public abstract class AbstractRpcLwM2MIntegrationTest extends AbstractLwM2MInteg }); } }); - String ver_Id_0 = lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(OBJECT_ID_0).version; - String ver_Id_1 = lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(OBJECT_ID_1).version; - objectIdVer_0 = "/" + OBJECT_ID_0 + "_" + ver_Id_0; - objectIdVer_1 = "/" + OBJECT_ID_1 + "_" + ver_Id_1; + String ver_Id_0 = lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(SECURITY).version; + String ver_Id_1 = lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(SERVER).version; + objectIdVer_0 = "/" + SECURITY + "_" + ver_Id_0; + objectIdVer_1 = "/" + SERVER + "_" + ver_Id_1; objectIdVer_2 = (String) expectedObjectIdVers.stream().filter(path -> ((String) path).startsWith("/" + ACCESS_CONTROL)).findFirst().get(); objectIdVer_3 = (String) expectedObjectIdVers.stream().filter(PREDICATE_3).findFirst().get(); objectIdVer_19 = (String) expectedObjectIdVers.stream().filter(path -> ((String) path).startsWith("/" + BINARY_APP_DATA_CONTAINER)).findFirst().get(); @@ -221,10 +225,67 @@ public abstract class AbstractRpcLwM2MIntegrationTest extends AbstractLwM2MInteg " ],\n" + " \"attributeLwm2m\": {}\n" + " }"; + String INIT_READ_TELEMETRY_ATTRIBUTE_AS_OBSERVE_STRATEGY_ALL = + " {\n" + + " \"keyName\": {\n" + + " \"/3_1.2/0/9\": \"batteryLevel\",\n" + + " \"/3_1.2/0/20\": \"batteryStatus\",\n" + + " \"/19_1.1/0/2\": \"dataCreationTime\",\n" + + " \"/5_1.2/0/6\": \"pkgname\",\n" + + " \"/5_1.2/0/7\": \"pkgversion\",\n" + + " \"/5_1.2/0/9\": \"firmwareUpdateDeliveryMethod\"\n" + + " },\n" + + " \"observe\": [\n" + + " \"/3_1.2/0/20\"\n" + + " ],\n" + + " \"attribute\": [\n" + + " \"/5_1.2/0/6\",\n" + + " \"/5_1.2/0/7\"\n" + + " ],\n" + + " \"telemetry\": [\n" + + " \"/3_1.2/0/9\",\n" + + " \"/3_1.2/0/20\",\n" + + " \"/5_1.2/0/9\",\n" + + " \"/19_1.1/0/2\"\n" + + " ],\n" + + " \"attributeLwm2m\": {},\n" + + " \"initAttrTelAsObsStrategy\": true,\n" + + " \"observeStrategy\": 1\n" + + " }"; + String INIT_READ_TELEMETRY_ATTRIBUTE_AS_OBSERVE_STRATEGY_BY_OBJECT = + " {\n" + + " \"keyName\": {\n" + + " \"/3_1.2/0/9\": \"batteryLevel\",\n" + + " \"/3_1.2/0/20\": \"batteryStatus\",\n" + + " \"/19_1.1/0/2\": \"dataCreationTime\",\n" + + " \"/5_1.2/0/6\": \"pkgname\",\n" + + " \"/5_1.2/0/7\": \"pkgversion\",\n" + + " \"/5_1.2/0/9\": \"firmwareUpdateDeliveryMethod\"\n" + + " },\n" + + " \"observe\": [\n" + + " \"/3_1.2/0/9\"\n" + + " ],\n" + + " \"attribute\": [\n" + + " \"/5_1.2/0/6\",\n" + + " \"/5_1.2/0/7\"\n" + + " ],\n" + + " \"telemetry\": [\n" + + " \"/3_1.2/0/9\",\n" + + " \"/3_1.2/0/20\",\n" + + " \"/5_1.2/0/9\",\n" + + " \"/19_1.1/0/2\"\n" + + " ],\n" + + " \"attributeLwm2m\": {},\n" + + " \"initAttrTelAsObsStrategy\": true,\n" + + " \"observeStrategy\": 2\n" + + " }"; + CONFIG_PROFILE_WITH_PARAMS_RPC = switch (typeConfigProfile) { case 0 -> ATTRIBUTES_TELEMETRY_WITH_PARAMS_RPC_WITH_OBSERVE; case 1 -> TELEMETRY_WITH_PARAMS_RPC_WITHOUT_OBSERVE; + case 2 -> INIT_READ_TELEMETRY_ATTRIBUTE_AS_OBSERVE_STRATEGY_ALL; + case 3 -> INIT_READ_TELEMETRY_ATTRIBUTE_AS_OBSERVE_STRATEGY_BY_OBJECT; case 3303 -> TELEMETRY_WITH_PARAMS_RPC_COLLECTED_VALUE; default -> throw new IllegalStateException("Unexpected value: " + typeConfigProfile); }; @@ -264,19 +325,6 @@ public abstract class AbstractRpcLwM2MIntegrationTest extends AbstractLwM2MInteg .count(); } - protected void updateRegAtLeastOnceAfterAction() { - long initialInvocationCount = countUpdateReg(); - AtomicLong newInvocationCount = new AtomicLong(initialInvocationCount); - log.trace("updateRegAtLeastOnceAfterAction: initialInvocationCount [{}]", initialInvocationCount); - await("Update Registration at-least-once after action") - .atMost(50, TimeUnit.SECONDS) - .until(() -> { - newInvocationCount.set(countUpdateReg()); - return newInvocationCount.get() > initialInvocationCount; - }); - log.trace("updateRegAtLeastOnceAfterAction: newInvocationCount [{}]", newInvocationCount.get()); - } - protected long countSendParametersOnThingsboardTelemetryResource(String rezName) { return Mockito.mockingDetails(lwM2mTransportServerHelperTest) .getInvocations().stream() @@ -290,4 +338,36 @@ public abstract class AbstractRpcLwM2MIntegrationTest extends AbstractLwM2MInteg ) .count(); } + + protected String sendDiscover(String path) throws Exception { + String setRpcRequest = "{\"method\": \"Discover\", \"params\": {\"id\": \"" + path + "\"}}"; + return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), setRpcRequest, String.class, status().isOk()); + } + + protected String sendRpcObserveReadAllWithResult() throws Exception { + ObjectNode rpcActualResult = sendRpcObserveWithResult("ObserveReadAll", null); + assertEquals(ResponseCode.CONTENT.getName(), rpcActualResult.get("result").asText()); + return rpcActualResult.get("value").asText(); + } + + protected String sendRpcObserveReadAllWithResult(String params) throws Exception { + sendRpcObserveOk("Observe", params); + ObjectNode rpcActualResult = sendRpcObserveWithResult("ObserveReadAll", null); + assertEquals(ResponseCode.CONTENT.getName(), rpcActualResult.get("result").asText()); + return rpcActualResult.get("value").asText(); + } + + protected void testObserveOneResourceValue_Count_4_CancelAll_Reboot_After_Observe_Count_4(String expectedIdVer) throws Exception { + String expectedIdObserve = "SingleObservation:/3/0/9"; + sendObserveCancelAllWithAwait(lwM2MTestClient.getDeviceIdStr()); + updateRegAtLeastOnceAfterAction(); + lwM2MTestClient.getLeshanClient().stop(false); + lwM2MTestClient.getLeshanClient().start(); + updateRegAtLeastOnceAfterAction(); + awaitObserveReadAll(4,lwM2MTestClient.getDeviceIdStr()); + String actualIdVer = sendDiscover(objectIdVer_3); + assertTrue(actualIdVer.contains(expectedIdVer)); + String actualAllObserve = sendRpcObserveReadAllWithResult(); + assertTrue(actualAllObserve.contains(expectedIdObserve)); + } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverTest.java index dceadfb7b5..77960b1c54 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverTest.java @@ -192,11 +192,6 @@ public class RpcLwm2mIntegrationDiscoverTest extends AbstractRpcLwM2MIntegration assertTrue(rpcActualResult.get("error").asText().contains(expected)); } - private String sendDiscover(String path) throws Exception { - String setRpcRequest = "{\"method\": \"Discover\", \"params\": {\"id\": \"" + path + "\"}}"; - return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), setRpcRequest, String.class, status().isOk()); - } - private String convertObjectIdToVerId(String path, String ver) { ver = ver != null ? ver : TbLwM2mVersion.VERSION_1_0.getVersion().toString(); try { diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverWriteAttributesTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverWriteAttributesTest.java index bd1216eb06..2a4a401107 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverWriteAttributesTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverWriteAttributesTest.java @@ -166,9 +166,4 @@ public class RpcLwm2mIntegrationDiscoverWriteAttributesTest extends AbstractRpcL String setRpcRequest = "{\"method\": \"WriteAttributes\", \"params\": {\"id\": \"" + path + "\", \"attributes\": " + value + " }}"; return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), setRpcRequest, String.class, status().isOk()); } - - private String sendDiscover(String path) throws Exception { - String setRpcRequest = "{\"method\": \"Discover\", \"params\": {\"id\": \"" + path + "\"}}"; - return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), setRpcRequest, String.class, status().isOk()); - } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationInitReadCompositeAllTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationInitReadCompositeAllTest.java new file mode 100644 index 0000000000..62af7f00f3 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationInitReadCompositeAllTest.java @@ -0,0 +1,92 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.lwm2m.rpc.sql; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationTest; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_0; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_2; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_6; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_7; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_9; + +@Slf4j +public class RpcLwm2mIntegrationInitReadCompositeAllTest extends AbstractRpcLwM2MIntegrationTest { + + /** + " \"/3_1.2/0/9\": \"batteryLevel\", - Telemetry + " \"/3_1.2/0/20\": \"batteryStatus\" - Observe, Telemetry + " \"/5_1.2/0/6\": \"pkgname\" - Attributes + " \"/5_1.2/0/7\": \"pkgversion\" - Attributes + " \"/5_1.2/0/9\": \"firmwareUpdateDeliveryMethod\"\ - Telemetry + " \"/19_1.1/0/2\": \"dataCreationTime\" - Telemetry + * "observeStrategy": 1 + */ + @Test + public void testInitReadCompositeAsObserveStrategyCompositeAll() throws Exception { + + + // init test + String RESOURCE_3_9 = "batteryLevel"; + String RESOURCE_3_20 = "batteryStatus"; + String RESOURCE_5_6 = "pkgname"; + String RESOURCE_5_7 = "pkgversion"; + String RESOURCE_5_9 = "firmwareUpdateDeliveryMethod"; + String RESOURCE_19_2 = "dataCreationTime"; + + String idVwr_3_0_20 = idVer_3_0_9 = objectIdVer_3 + "/" + OBJECT_INSTANCE_ID_0 + "/" + 20; + String IdVer5_0_6 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_6; + String IdVer5_0_7 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_7; + String IdVer5_0_9 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_9; + String idVer_19_0_2 = objectIdVer_19 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_2; + countUpdateAttrTelemetryResource(idVer_3_0_9); + countUpdateAttrTelemetryResource(idVwr_3_0_20); + countUpdateAttrTelemetryResource(IdVer5_0_6); + countUpdateAttrTelemetryResource(IdVer5_0_7); + countUpdateAttrTelemetryResource(IdVer5_0_9); + countUpdateAttrTelemetryResource(idVer_19_0_2); + + + AtomicReference actualValues = new AtomicReference<>(); + await().atMost(40, SECONDS).until(() -> { + actualValues.set(doGetAsync( + "/api/plugins/telemetry/DEVICE/" + lwM2MTestClient.getDeviceIdStr() + "/values/timeseries?keys=" + + RESOURCE_3_9 + "," + RESOURCE_3_20 + "," + RESOURCE_5_9 + "," + RESOURCE_19_2, ObjectNode.class)); + return actualValues.get() != null && !actualValues.get().isEmpty() + && !actualValues.get().get(RESOURCE_3_9).isEmpty() + && !actualValues.get().get(RESOURCE_3_20).isEmpty() + && !actualValues.get().get(RESOURCE_5_9).isEmpty() + && !actualValues.get().get(RESOURCE_19_2).isEmpty(); + }); + + AtomicReference> actualKeys =new AtomicReference<>(); + await().atMost(40, SECONDS).until(() -> { + actualKeys.set(doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + lwM2MTestClient.getDeviceIdStr() + "/keys/attributes/CLIENT_SCOPE", new TypeReference<>() { + })); + return actualKeys.get() != null && !actualKeys.get().isEmpty() && !actualKeys.get().isEmpty() + && actualKeys.get().contains(RESOURCE_5_6)&& actualKeys.get().contains(RESOURCE_5_7); + }); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationInitReadCompositeByObjectTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationInitReadCompositeByObjectTest.java new file mode 100644 index 0000000000..aea9755d0c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationInitReadCompositeByObjectTest.java @@ -0,0 +1,92 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.lwm2m.rpc.sql; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationTest; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_0; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_2; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_6; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_7; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_9; + +@Slf4j +public class RpcLwm2mIntegrationInitReadCompositeByObjectTest extends AbstractRpcLwM2MIntegrationTest { + + /** + " \"/3_1.2/0/9\": \"batteryLevel\", - Telemetry + " \"/3_1.2/0/20\": \"batteryStatus\" - Observe, Telemetry + " \"/5_1.2/0/6\": \"pkgname\" - Attributes + " \"/5_1.2/0/7\": \"pkgversion\" - Attributes + " \"/5_1.2/0/9\": \"firmwareUpdateDeliveryMethod\"\ - Telemetry + " \"/19_1.1/0/2\": \"dataCreationTime\" - Telemetry + * "observeStrategy": 2 + */ + @Test + public void testInitReadCompositeAsObserveStrategyCompositeByObject() throws Exception { + + + // init test + String RESOURCE_3_9 = "batteryLevel"; + String RESOURCE_3_20 = "batteryStatus"; + String RESOURCE_5_6 = "pkgname"; + String RESOURCE_5_7 = "pkgversion"; + String RESOURCE_5_9 = "firmwareUpdateDeliveryMethod"; + String RESOURCE_19_2 = "dataCreationTime"; + + String idVwr_3_0_20 = idVer_3_0_9 = objectIdVer_3 + "/" + OBJECT_INSTANCE_ID_0 + "/" + 20; + String IdVer5_0_6 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_6; + String IdVer5_0_7 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_7; + String IdVer5_0_9 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_9; + String idVer_19_0_2 = objectIdVer_19 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_2; + countUpdateAttrTelemetryResource(idVer_3_0_9); + countUpdateAttrTelemetryResource(idVwr_3_0_20); + countUpdateAttrTelemetryResource(IdVer5_0_6); + countUpdateAttrTelemetryResource(IdVer5_0_7); + countUpdateAttrTelemetryResource(IdVer5_0_9); + countUpdateAttrTelemetryResource(idVer_19_0_2); + + + AtomicReference actualValues = new AtomicReference<>(); + await().atMost(40, SECONDS).until(() -> { + actualValues.set(doGetAsync( + "/api/plugins/telemetry/DEVICE/" + lwM2MTestClient.getDeviceIdStr() + "/values/timeseries?keys=" + + RESOURCE_3_9 + "," + RESOURCE_3_20 + "," + RESOURCE_5_9 + "," + RESOURCE_19_2, ObjectNode.class)); + return actualValues.get() != null && !actualValues.get().isEmpty() + && !actualValues.get().get(RESOURCE_3_9).isEmpty() + && !actualValues.get().get(RESOURCE_3_20).isEmpty() + && !actualValues.get().get(RESOURCE_5_9).isEmpty() + && !actualValues.get().get(RESOURCE_19_2).isEmpty(); + }); + + AtomicReference> actualKeys =new AtomicReference<>(); + await().atMost(40, SECONDS).until(() -> { + actualKeys.set(doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + lwM2MTestClient.getDeviceIdStr() + "/keys/attributes/CLIENT_SCOPE", new TypeReference<>() { + })); + return actualKeys.get() != null && !actualKeys.get().isEmpty() && !actualKeys.get().isEmpty() + && actualKeys.get().contains(RESOURCE_5_6)&& actualKeys.get().contains(RESOURCE_5_7); + }); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveTest.java index 483442382d..505cebff69 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveTest.java @@ -335,12 +335,5 @@ public class RpcLwm2mIntegrationObserveTest extends AbstractRpcLwM2MIntegrationT sendRpcObserveOk("Observe", expectedId_1); sendRpcObserveOk("Observe", expectedId_2); } - - private String sendRpcObserveReadAllWithResult(String params) throws Exception { - sendRpcObserveOk("Observe", params); - ObjectNode rpcActualResult = sendRpcObserveWithResult("ObserveReadAll", null); - assertEquals(ResponseCode.CONTENT.getName(), rpcActualResult.get("result").asText()); - return rpcActualResult.get("value").asText(); - } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserve_Ver_1_0_Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer10Test.java similarity index 71% rename from application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserve_Ver_1_0_Test.java rename to application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer10Test.java index 3d3640cb2e..e6f035e5cc 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserve_Ver_1_0_Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer10Test.java @@ -19,13 +19,14 @@ import lombok.extern.slf4j.Slf4j; import org.junit.Before; import org.junit.Test; import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationObserve_Ver_1_0_Test; -import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationObserve_Ver_1_1_Test; - import static org.junit.Assert.assertTrue; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_NAME_3_9; @Slf4j -public class RpcLwm2mIntegrationObserve_Ver_1_0_Test extends AbstractRpcLwM2MIntegrationObserve_Ver_1_0_Test { +public class RpcLwm2mIntegrationObserveVer10Test extends AbstractRpcLwM2MIntegrationObserve_Ver_1_0_Test { + + public RpcLwm2mIntegrationObserveVer10Test() throws Exception { + } @Before public void setupObserveTest() throws Exception { @@ -44,5 +45,21 @@ public class RpcLwm2mIntegrationObserve_Ver_1_0_Test extends AbstractRpcLwM2MInt updateRegAtLeastOnceAfterAction(); long lastSendTelemetryAtCount = countSendParametersOnThingsboardTelemetryResource(RESOURCE_ID_NAME_3_9); assertTrue(lastSendTelemetryAtCount > initSendTelemetryAtCount); + awaitObserveReadAll(1,lwM2MTestClient.getDeviceIdStr()); + } + + /** + * "3_1.0/0/9" + * Observe count 4 + * CancelAll Observe + * Reboot + * Observe count 4 contains + * "/3_1.0" - Discover Object - find ver + * @throws Exception + */ + @Test + public void testObserveOneResourceValue_Count_4_CancelAll_Reboot_After_Observe_Count_4_ObjectVer_1_0() throws Exception { + String expectedIdVer = ";ver=1.0"; + testObserveOneResourceValue_Count_4_CancelAll_Reboot_After_Observe_Count_4(expectedIdVer); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserve_Ver_1_1_Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer11Test.java similarity index 72% rename from application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserve_Ver_1_1_Test.java rename to application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer11Test.java index a4f7727773..a64ff43b92 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserve_Ver_1_1_Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer11Test.java @@ -23,7 +23,10 @@ import static org.junit.Assert.assertTrue; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_NAME_3_9; @Slf4j -public class RpcLwm2mIntegrationObserve_Ver_1_1_Test extends AbstractRpcLwM2MIntegrationObserve_Ver_1_1_Test { +public class RpcLwm2mIntegrationObserveVer11Test extends AbstractRpcLwM2MIntegrationObserve_Ver_1_1_Test { + + public RpcLwm2mIntegrationObserveVer11Test() throws Exception { + } @Before public void setupObserveTest() throws Exception { @@ -43,4 +46,19 @@ public class RpcLwm2mIntegrationObserve_Ver_1_1_Test extends AbstractRpcLwM2MInt long lastSendTelemetryAtCount = countSendParametersOnThingsboardTelemetryResource(RESOURCE_ID_NAME_3_9); assertTrue(lastSendTelemetryAtCount > initSendTelemetryAtCount); } + + /** + * "3_1.1/0/9" + * Observe count 4 + * CancelAll Observe + * Reboot + * Observe count 4 contains + * "/3" - Discover Object - find ver (lwm2mVersion == 1.1) + * @throws Exception + */ + @Test + public void testObserveOneResourceValue_Count_4_CancelAll_Reboot_After_Observe_Count_4_ObjectVer_1_1() throws Exception { + String expectedIdVer = ""; + testObserveOneResourceValue_Count_4_CancelAll_Reboot_After_Observe_Count_4(expectedIdVer); + } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserve_Ver_1_2_Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer12Test.java similarity index 73% rename from application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserve_Ver_1_2_Test.java rename to application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer12Test.java index a166e0139b..89062dd303 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserve_Ver_1_2_Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer12Test.java @@ -18,14 +18,16 @@ package org.thingsboard.server.transport.lwm2m.rpc.sql; import lombok.extern.slf4j.Slf4j; import org.junit.Before; import org.junit.Test; -import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationObserve_Ver_1_0_Test; import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationObserve_Ver_1_2_Test; import static org.junit.Assert.assertTrue; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_NAME_3_9; @Slf4j -public class RpcLwm2mIntegrationObserve_Ver_1_2_Test extends AbstractRpcLwM2MIntegrationObserve_Ver_1_2_Test { +public class RpcLwm2mIntegrationObserveVer12Test extends AbstractRpcLwM2MIntegrationObserve_Ver_1_2_Test { + + public RpcLwm2mIntegrationObserveVer12Test() throws Exception { + } @Before public void setupObserveTest() throws Exception { @@ -45,4 +47,19 @@ public class RpcLwm2mIntegrationObserve_Ver_1_2_Test extends AbstractRpcLwM2MInt long lastSendTelemetryAtCount = countSendParametersOnThingsboardTelemetryResource(RESOURCE_ID_NAME_3_9); assertTrue(lastSendTelemetryAtCount > initSendTelemetryAtCount); } + + /** + * "3_1.2/0/9" + * Observe count 4 + * CancelAll Observe + * Reboot + * Observe count 4 contains + * "/3_1.2" - Discover Object - find ver + * @throws Exception + */ + @Test + public void testObserveOneResourceValue_Count_4_CancelAll_Reboot_After_Observe_Count_4_ObjectVer_1_2() throws Exception { + String expectedIdVer = ";ver=1.2"; + testObserveOneResourceValue_Count_4_CancelAll_Reboot_After_Observe_Count_4(expectedIdVer); + } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadTest.java index 90b373b16c..c0779ad944 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadTest.java @@ -21,8 +21,10 @@ import org.eclipse.leshan.core.ResponseCode; import org.eclipse.leshan.core.node.LwM2mPath; import org.junit.Test; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationTest; +import static org.eclipse.leshan.core.LwM2mId.ACCESS_CONTROL; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -53,18 +55,18 @@ public class RpcLwm2mIntegrationReadTest extends AbstractRpcLwM2MIntegrationTest try { expectedObjectIdVers.forEach(expected -> { try { - String actualResult = sendRPCById((String) expected); String expectedObjectId = pathIdVerToObjectId((String) expected); LwM2mPath expectedPath = new LwM2mPath(expectedObjectId); - ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); - assertEquals(ResponseCode.CONTENT.getName(), rpcActualResult.get("result").asText()); - String expectedObjectInstances = "LwM2mObject [id=" + expectedPath.getObjectId() + ", instances={0=LwM2mObjectInstance [id=0, resources="; - if (expectedPath.getObjectId() == 1) { - expectedObjectInstances = "LwM2mObject [id=1, instances={1="; - } else if (expectedPath.getObjectId() == 2) { - expectedObjectInstances = "LwM2mObject [id=2, instances={}]"; + if (expectedPath.getObjectId() > ACCESS_CONTROL) { + String actualResult = sendRPCByIdSync((String) expected); + if (StringUtils.isNoneBlank(actualResult)) { + log.warn(" expectedPath: [{}]", expectedPath); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.CONTENT.getName(), rpcActualResult.get("result").asText()); + String expectedObjectInstances = "LwM2mObject [id=" + expectedPath.getObjectId() + ", instances={0=LwM2mObjectInstance [id=0, resources="; + assertTrue(rpcActualResult.get("value").asText().contains(expectedObjectInstances)); + } } - assertTrue(rpcActualResult.get("value").asText().contains(expectedObjectInstances)); } catch (Exception e) { e.printStackTrace(); } @@ -83,7 +85,7 @@ public class RpcLwm2mIntegrationReadTest extends AbstractRpcLwM2MIntegrationTest public void testReadAllInstancesInClientById_Result_CONTENT_Value_IsInstances_IsResources() throws Exception { expectedObjectIdVerInstances.forEach(expected -> { try { - String actualResult = sendRPCById((String) expected); + String actualResult = sendRPCByIdAsync((String) expected); String expectedObjectId = pathIdVerToObjectId((String) expected); LwM2mPath expectedPath = new LwM2mPath(expectedObjectId); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); @@ -104,7 +106,7 @@ public class RpcLwm2mIntegrationReadTest extends AbstractRpcLwM2MIntegrationTest @Test public void testReadMultipleResourceById_Result_CONTENT_Value_IsLwM2mMultipleResource() throws Exception { String expectedIdVer = objectInstanceIdVer_3 + "/" + RESOURCE_ID_11; - String actualResult = sendRPCById(expectedIdVer); + String actualResult = sendRPCByIdAsync(expectedIdVer); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); assertEquals(ResponseCode.CONTENT.getName(), rpcActualResult.get("result").asText()); String expected = "LwM2mMultipleResource [id=" + RESOURCE_ID_11 + ", values={"; @@ -117,7 +119,7 @@ public class RpcLwm2mIntegrationReadTest extends AbstractRpcLwM2MIntegrationTest @Test public void testReadSingleResourceById_Result_CONTENT_Value_IsLwM2mSingleResource() throws Exception { String expectedIdVer = objectInstanceIdVer_3 + "/" + RESOURCE_ID_14; - String actualResult = sendRPCById(expectedIdVer); + String actualResult = sendRPCByIdAsync(expectedIdVer); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); assertEquals(ResponseCode.CONTENT.getName(), rpcActualResult.get("result").asText()); String expected = "LwM2mSingleResource [id=" + RESOURCE_ID_14 + ", value="; @@ -228,10 +230,14 @@ public class RpcLwm2mIntegrationReadTest extends AbstractRpcLwM2MIntegrationTest assertEquals(actualValue, expectedValue); } - private String sendRPCById(String path) throws Exception { + private String sendRPCByIdAsync(String path) throws Exception { String setRpcRequest = "{\"method\": \"Read\", \"params\": {\"id\": \"" + path + "\"}}"; return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), setRpcRequest, String.class, status().isOk()); } + private String sendRPCByIdSync(String path) throws Exception { + String setRpcRequest = "{\"method\": \"Read\", \"params\": {\"id\": \"" + path + "\"}}"; + return doPost("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), setRpcRequest, String.class, status().isOk()); + } private String sendRPCByKey(String key) throws Exception { String setRpcRequest = "{\"method\": \"Read\", \"params\": {\"key\": \"" + key + "\"}}"; diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/AbstractSecurityLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/AbstractSecurityLwM2MIntegrationTest.java index 6dda2de9fe..e642035b32 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/AbstractSecurityLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/AbstractSecurityLwM2MIntegrationTest.java @@ -22,6 +22,7 @@ import org.eclipse.leshan.client.object.Security; import org.eclipse.leshan.core.ResponseCode; import org.eclipse.leshan.core.util.Hex; import org.junit.Assert; +import org.junit.Before; import org.springframework.test.web.servlet.MvcResult; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; @@ -69,6 +70,7 @@ import java.util.concurrent.TimeUnit; import static org.awaitility.Awaitility.await; import static org.eclipse.leshan.client.object.Security.noSecBootstrap; import static org.eclipse.leshan.client.object.Security.psk; +import static org.eclipse.leshan.core.LwM2mId.SERVER; import static org.junit.Assert.assertEquals; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.PSK; @@ -77,7 +79,7 @@ import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClient import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_REGISTRATION_STARTED; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_REGISTRATION_SUCCESS; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_UPDATE_SUCCESS; -import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_ID_1; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_0; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_9; @DaoSqlTest @@ -95,7 +97,6 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M protected static final String SERVER_STORE_PWD = "server_ks_password"; protected static final String SERVER_CERT_ALIAS = "server"; protected static final String SERVER_CERT_ALIAS_BS = "bootstrap"; - protected static final Security SECURITY_NO_SEC_BS = noSecBootstrap(URI_BS);; protected final X509Certificate serverX509Cert; // server certificate signed by rootCA protected final X509Certificate serverX509CertBs; // serverBs certificate signed by rootCA protected final PublicKey serverPublicKeyFromCert; // server public key used for RPK @@ -119,7 +120,6 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M protected final PrivateKey clientPrivateKeyFromCertTrust; // client private key used for X509 and RPK protected final X509Certificate clientX509CertTrustNo; // client certificate signed by intermediate, rootCA with a good CN ("host name") protected final PrivateKey clientPrivateKeyFromCertTrustNo; // client private key used for X509 and RPK - private final String[] RESOURCES_SECURITY = new String[]{"1.xml", "2.xml", "3.xml", "5.xml", "9.xml", "19.xml"}; private final LwM2MBootstrapClientCredentials defaultBootstrapCredentials; @@ -134,7 +134,6 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M public AbstractSecurityLwM2MIntegrationTest() { // create client credentials - setResources(this.RESOURCES_SECURITY); try { // Get certificates from key store char[] clientKeyStorePwd = CLIENT_STORE_PWD.toCharArray(); @@ -178,20 +177,26 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M defaultBootstrapCredentials.setLwm2mServer(serverCredentials); } - public void basicTestConnectionBefore(String clientEndpoint, - String awaitAlias, - LwM2MProfileBootstrapConfigType type, - Set expectedStatuses, - LwM2MClientState finishState) throws Exception { + @Before + public void init() throws Exception { + String[] RESOURCES_SECURITY = new String[]{"3-1_2.xml", "5.xml", "6.xml", "9.xml", "19.xml"}; + setResources(RESOURCES_SECURITY); + } + + public void basicTestConnectionStartBS(String clientEndpoint, + String awaitAlias, + LwM2MProfileBootstrapConfigType type, + Set expectedStatuses, + LwM2MClientState finishState) throws Exception { Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsNoSec(type)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsNoSec(createNoSecClientCredentials(clientEndpoint)); - this.basicTestConnection(null , SECURITY_NO_SEC_BS, + this.basicTestConnection(null , noSecBootstrap(URI_BS), deviceCredentials, clientEndpoint, transportConfiguration, awaitAlias, expectedStatuses, - true, + false, finishState, false); } @@ -231,12 +236,19 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M } - public void basicTestConnectionBootstrapRequestTriggerBefore(String clientEndpoint, String awaitAlias, LwM2MProfileBootstrapConfigType type) throws Exception { - Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsNoSec(type)); + public void basicTestConnectionBootstrapRequestTriggerBefore(String clientEndpoint, String awaitAlias, LwM2MProfileBootstrapConfigType type, int cnt) throws Exception { + List bootstrapServerCredentialsNoSec = getBootstrapServerCredentialsNoSec(type); + for (int i = 2; i <= cnt; i++) { + AbstractLwM2MBootstrapServerCredential bsCredential = getBootstrapServerCredentialNoSec(false); + bsCredential.setHost("0.0.0." + i); + bsCredential.setShortServerId(bsCredential.getShortServerId() + i); + bootstrapServerCredentialsNoSec.add(bsCredential); + } + Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, bootstrapServerCredentialsNoSec); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsNoSec(createNoSecClientCredentials(clientEndpoint)); this.basicTestConnectionBootstrapRequestTrigger( SECURITY_NO_SEC, - SECURITY_NO_SEC_BS, + noSecBootstrap(URI_BS), deviceCredentials, clientEndpoint, transportConfiguration, @@ -275,8 +287,8 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M }); Assert.assertTrue(lwM2MTestClient.getClientStates().containsAll(expectedStatusesLwm2m)); - String executedPath = "/" + OBJECT_ID_1 + "_" + lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(OBJECT_ID_1).version - + "/" +serverId + "/" + RESOURCE_ID_9; + String executedPath = "/" + SERVER + "_" + lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(SERVER).version + + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_9; lwM2MTestClient.setClientStates(new HashSet<>()); String actualResult = sendRPCSecurityExecuteById(executedPath, deviceIdStr, endpoint); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); @@ -352,7 +364,7 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M default: throw new IllegalStateException("Unexpected value: " + mode); } - bootstrapServerCredential.setShortServerId(isBootstrap ? shortServerIdBs0 : shortServerId); + bootstrapServerCredential.setShortServerId(isBootstrap ? null : shortServerId); bootstrapServerCredential.setBootstrapServerIs(isBootstrap); bootstrapServerCredential.setHost(isBootstrap ? hostBs : host); bootstrapServerCredential.setPort(isBootstrap ? securityPortBs : securityPort); diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBS3SectionTriggerTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBS3SectionTriggerTest.java new file mode 100644 index 0000000000..af8284484d --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBS3SectionTriggerTest.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.lwm2m.security.sql; + +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.LWM2M_ONLY; +public class NoSecLwM2MIntegrationBS3SectionTriggerTest extends AbstractSecurityLwM2MIntegrationTest { + + @Test + public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTrigger_3_ConnectBsSuccess_UpdateLwm2mSection_3_AndLm2m_1_ConnectLwm2mSuccess() throws Exception { + String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger_3" + LWM2M_ONLY.name(); + String awaitAlias = "await on client state (NoSecBS Trigger Lwm2m section)"; + basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, LWM2M_ONLY, 3); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSLwm2mOnlyNoneTriggerOneSectionTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSLwm2mOnlyNoneTriggerOneSectionTest.java new file mode 100644 index 0000000000..4fceba60f3 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSLwm2mOnlyNoneTriggerOneSectionTest.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.lwm2m.security.sql; + +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.LWM2M_ONLY; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; +public class NoSecLwM2MIntegrationBSLwm2mOnlyNoneTriggerOneSectionTest extends AbstractSecurityLwM2MIntegrationTest { + + @Test + public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateLwm2mSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { + String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + LWM2M_ONLY.name(); + String awaitAlias = "await on client state (NoSecBS Trigger Lwm2m section)"; + basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, LWM2M_ONLY, 1); + } + + @Test + public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateNoneSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { + String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + NONE.name(); + String awaitAlias = "await on client state (NoSecBS Trigger None section)"; + basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, NONE, 1); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSNoTriggerTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSNoTriggerTest.java new file mode 100644 index 0000000000..b218c39aec --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSNoTriggerTest.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.lwm2m.security.sql; + +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_REGISTRATION_SUCCESS; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.BOTH; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.LWM2M_ONLY; + +public class NoSecLwM2MIntegrationBSNoTriggerTest extends AbstractSecurityLwM2MIntegrationTest { + + @Test + public void testWithNoSecConnectBsSuccess_UpdateTwoSectionsBootstrapAndLm2m_ConnectLwm2mSuccess() throws Exception { + String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "NoTrigger" + BOTH.name(); + String awaitAlias = "await on client state (NoSecBS two section)"; + basicTestConnectionStartBS(clientEndpoint, awaitAlias, BOTH, expectedStatusesRegistrationBsSuccess, ON_REGISTRATION_SUCCESS); + } + + @Test + public void testWithNoSecConnectBsSuccess_UpdateLwm2mSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { + String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "NoTrigger" + LWM2M_ONLY.name(); + String awaitAlias = "await on client state (NoSecBS Lwm2m section)"; + basicTestConnectionStartBS(clientEndpoint, awaitAlias, LWM2M_ONLY, expectedStatusesRegistrationBsSuccess, ON_REGISTRATION_SUCCESS); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSOnlyTriggerOneSectionTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSOnlyTriggerOneSectionTest.java new file mode 100644 index 0000000000..5510cdf614 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSOnlyTriggerOneSectionTest.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.lwm2m.security.sql; + +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.BOOTSTRAP_ONLY; +public class NoSecLwM2MIntegrationBSOnlyTriggerOneSectionTest extends AbstractSecurityLwM2MIntegrationTest { + + @Test + public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateBootstrapSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { + String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + BOOTSTRAP_ONLY.name(); + String awaitAlias = "await on client state (NoSecBS Trigger Bootstrap section)"; + basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, BOOTSTRAP_ONLY, 1); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSTriggerTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSTriggerTest.java new file mode 100644 index 0000000000..b007d16bde --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSTriggerTest.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.lwm2m.security.sql; + +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.BOTH; + +public class NoSecLwM2MIntegrationBSTriggerTest extends AbstractSecurityLwM2MIntegrationTest { + + @Test + public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateTwoSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { + String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + BOTH.name(); + String awaitAlias = "await on client state (NoSecBS Trigger Two section)"; + basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, BOTH, 1); + } +} + diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationTest.java index d4a464b9c6..50bb87467f 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationTest.java @@ -19,12 +19,6 @@ import org.junit.Test; import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MDeviceCredentials; import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; -import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_REGISTRATION_SUCCESS; -import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.BOOTSTRAP_ONLY; -import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.BOTH; -import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.LWM2M_ONLY; -import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; - public class NoSecLwM2MIntegrationTest extends AbstractSecurityLwM2MIntegrationTest { //Lwm2m only @@ -34,48 +28,4 @@ public class NoSecLwM2MIntegrationTest extends AbstractSecurityLwM2MIntegrationT LwM2MDeviceCredentials clientCredentials = getDeviceCredentialsNoSec(createNoSecClientCredentials(clientEndpoint)); super.basicTestConnectionObserveSingleTelemetry(SECURITY_NO_SEC, clientCredentials, clientEndpoint, false, false); } - - // Bootstrap + Lwm2m - @Test - public void testWithNoSecConnectBsSuccess_UpdateTwoSectionsBootstrapAndLm2m_ConnectLwm2mSuccess() throws Exception { - String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + BOTH.name(); - String awaitAlias = "await on client state (NoSecBS two section)"; - basicTestConnectionBefore(clientEndpoint, awaitAlias, BOTH, expectedStatusesRegistrationBsSuccess, ON_REGISTRATION_SUCCESS); - } - - @Test - public void testWithNoSecConnectBsSuccess_UpdateLwm2mSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { - String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + LWM2M_ONLY.name(); - String awaitAlias = "await on client state (NoSecBS Lwm2m section)"; - basicTestConnectionBefore(clientEndpoint, awaitAlias, LWM2M_ONLY, expectedStatusesRegistrationBsSuccess, ON_REGISTRATION_SUCCESS); - } - - // Bs trigger - @Test - public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateTwoSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { - String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + BOTH.name(); - String awaitAlias = "await on client state (NoSecBS Trigger Two section)"; - basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, BOTH); - } - - @Test - public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateBootstrapSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { - String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + BOOTSTRAP_ONLY.name(); - String awaitAlias = "await on client state (NoSecBS Trigger Bootstrap section)"; - basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, BOOTSTRAP_ONLY); - } - - @Test - public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateLwm2mSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { - String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + LWM2M_ONLY.name(); - String awaitAlias = "await on client state (NoSecBS Trigger Lwm2m section)"; - basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, LWM2M_ONLY); - } - - @Test - public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateNoneSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { - String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + NONE.name(); - String awaitAlias = "await on client state (NoSecBS Trigger None section)"; - basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, NONE); - } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java index 3b61dfe49f..a35b663052 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java @@ -58,8 +58,7 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes Hex.decodeHex(keyPsk.toCharArray())); Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, null, null, PSK, false); - this.basicTestConnection(security, - null, + this.basicTestConnection(security, null, deviceCredentials, clientEndpoint, transportConfiguration, @@ -69,10 +68,12 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes ON_REGISTRATION_SUCCESS, true); } + @Test public void testWithPskConnectLwm2mOneObserveSuccessUpdateProfileManyObserveUpdateRegistrationSuccess() throws Exception { - String clientEndpoint = CLIENT_ENDPOINT_PSK; - String identity = CLIENT_PSK_IDENTITY; + String suf = "UpdateReg"; + String clientEndpoint = CLIENT_ENDPOINT_PSK + "_" + suf; + String identity = CLIENT_PSK_IDENTITY + "_" + suf; String keyPsk = CLIENT_PSK_KEY; PSKClientCredential clientCredentials = new PSKClientCredential(); clientCredentials.setEndpoint(clientEndpoint); @@ -85,8 +86,7 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(TELEMETRY_WITH_ONE_OBSERVE, getBootstrapServerCredentialsSecure(PSK, NONE)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, null, null, PSK, false); String awaitAlias = "await on client state (Psk_Lwm2m)"; - Device lwm2mDevice = this.basicTestConnection(security, - null, + Device lwm2mDevice = this.basicTestConnection(security, null, deviceCredentials, clientEndpoint, transportConfiguration, @@ -105,10 +105,12 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes awaitObserveReadAll(2, lwm2mDevice.getId().getId().toString()); awaitUpdateReg(3); } + @Test public void testWithPskConnectLwm2mSuccessObserveSuccessUnRegClientUpdateProfileObserveConnectLwm2mSuccessOWithNewObserve() throws Exception { - String clientEndpoint = CLIENT_ENDPOINT_PSK; - String identity = CLIENT_PSK_IDENTITY; + String suf = "UnReg"; + String clientEndpoint = CLIENT_ENDPOINT_PSK + "_" + suf; + String identity = CLIENT_PSK_IDENTITY + "_" + suf; String keyPsk = CLIENT_PSK_KEY; PSKClientCredential clientCredentials = new PSKClientCredential(); clientCredentials.setEndpoint(clientEndpoint); @@ -121,8 +123,7 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(TELEMETRY_WITH_ONE_OBSERVE, getBootstrapServerCredentialsSecure(PSK, NONE)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, null, null, PSK, false); String awaitAlias = "await on client state (Psk_Lwm2m)"; - Device lwm2mDevice = this.basicTestConnection(security, - null, + Device lwm2mDevice = this.basicTestConnection(security, null, deviceCredentials, clientEndpoint, transportConfiguration, @@ -142,7 +143,7 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes Assert.assertNotNull(lwm2mDeviceProfileManyParams); lwM2MTestClient.start(true); - awaitObserveReadAll(2, lwm2mDevice.getId().getId().toString()); + awaitObserveReadAll(1, lwm2mDevice.getId().getId().toString()); awaitUpdateReg(3); } @@ -175,12 +176,12 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes clientCredentials.setEndpoint(clientEndpoint); clientCredentials.setIdentity(identity); clientCredentials.setKey(keyPsk); - Security securityBs = pskBootstrap(SECURE_URI_BS, + Security securityPskBs = pskBootstrap(SECURE_URI_BS, identity.getBytes(StandardCharsets.UTF_8), Hex.decodeHex(keyPsk.toCharArray())); Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, BOTH)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, null, null, PSK, false); - this.basicTestConnection(null, securityBs, + this.basicTestConnection(null, securityPskBs, deviceCredentials, clientEndpoint, transportConfiguration, diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/RpkLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/RpkLwM2MIntegrationTest.java index e94b8a6319..fbe84a28b2 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/RpkLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/RpkLwM2MIntegrationTest.java @@ -113,13 +113,13 @@ public class RpkLwM2MIntegrationTest extends AbstractSecurityLwM2MIntegrationTes RPKClientCredential clientCredentials = new RPKClientCredential(); clientCredentials.setEndpoint(clientEndpoint); clientCredentials.setKey(Base64.encodeBase64String(certificate.getPublicKey().getEncoded())); - Security securityBs = rpkBootstrap(SECURE_URI_BS, + Security securityRpkBs = rpkBootstrap(SECURE_URI_BS, certificate.getPublicKey().getEncoded(), privateKey.getEncoded(), serverX509CertBs.getPublicKey().getEncoded()); Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(RPK, BOTH)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, clientPrivateKeyFromCertTrust, certificate, RPK, false); - this.basicTestConnection(null, securityBs, + this.basicTestConnection(null, securityRpkBs, deviceCredentials, clientEndpoint, transportConfiguration, diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_NoTrustLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_NoTrustLwM2MIntegrationTest.java index 1ba408ac70..fd8a51b041 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_NoTrustLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_NoTrustLwM2MIntegrationTest.java @@ -51,15 +51,14 @@ public class X509_NoTrustLwM2MIntegrationTest extends AbstractSecurityLwM2MInteg X509ClientCredential clientCredentials = new X509ClientCredential(); clientCredentials.setEndpoint(clientEndpoint); clientCredentials.setCert(Base64.getEncoder().encodeToString(certificate.getEncoded())); - Security security = x509(SECURE_URI, + Security securityX509 = x509(SECURE_URI, shortServerId, certificate.getEncoded(), privateKey.getEncoded(), serverX509Cert.getEncoded()); Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(X509, NONE)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, privateKey, certificate, X509, false); - this.basicTestConnection(security, - null, + this.basicTestConnection(securityX509, null, deviceCredentials, clientEndpoint, transportConfiguration, @@ -119,8 +118,7 @@ public class X509_NoTrustLwM2MIntegrationTest extends AbstractSecurityLwM2MInteg serverX509CertBs.getEncoded()); Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(X509, BOTH)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, privateKey, certificate, X509, false); - this.basicTestConnection(security, - null, + this.basicTestConnection(security, null, deviceCredentials, clientEndpoint, transportConfiguration, diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_TrustLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_TrustLwM2MIntegrationTest.java index 81b708ba33..b7a6290741 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_TrustLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_TrustLwM2MIntegrationTest.java @@ -50,8 +50,7 @@ public class X509_TrustLwM2MIntegrationTest extends AbstractSecurityLwM2MIntegra serverX509Cert.getEncoded()); Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(X509, NONE)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, privateKey, certificate, X509, false); - this.basicTestConnection(security, - null, + this.basicTestConnection(security, null, deviceCredentials, clientEndpoint, transportConfiguration, @@ -77,8 +76,7 @@ public class X509_TrustLwM2MIntegrationTest extends AbstractSecurityLwM2MIntegra serverX509CertBs.getEncoded()); Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(X509, BOTH)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, privateKey, certificate, X509, false); - this.basicTestConnection(security, - null, + this.basicTestConnection(security,null, deviceCredentials, clientEndpoint, transportConfiguration, diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java index 6543955ed5..9856aa18cb 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java @@ -25,10 +25,10 @@ import org.springframework.boot.test.mock.mockito.SpyBean; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.msg.gateway.metrics.GatewayMetadata; import org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest; import org.thingsboard.server.transport.mqtt.MqttTestConfigProperties; import org.thingsboard.server.transport.mqtt.gateway.GatewayMetricsService; -import org.thingsboard.server.common.msg.gateway.metrics.GatewayMetadata; import org.thingsboard.server.transport.mqtt.gateway.metrics.GatewayMetricsState; import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestCallback; import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestClient; @@ -43,6 +43,7 @@ import java.util.Random; import java.util.Set; import java.util.concurrent.TimeUnit; +import static org.awaitility.Awaitility.await; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.mockito.Mockito.any; @@ -112,6 +113,42 @@ public abstract class AbstractMqttTimeseriesIntegrationTest extends AbstractMqtt processGatewayTelemetryTest(GATEWAY_TELEMETRY_TOPIC, expectedKeys, payload.getBytes(), deviceName1, deviceName2); } + @Test + public void testAckIsReceivedOnFailedPublishMessage() throws Exception { + String devicePayload = "[{\"ts\": 10000, \"values\": " + PAYLOAD_VALUES_STR + "}]"; + String payloadA = "{\"Device A\": " + devicePayload + "}"; + + String deviceBPayload = "[{\"ts\": 10000, \"values\": " + PAYLOAD_VALUES_STR + "}]"; + String payloadB = "{\"Device B\": " + deviceBPayload + "}"; + + testAckIsReceivedOnFailedPublishMessage("Device A", payloadA.getBytes(), "Device B", payloadB.getBytes()); + } + + protected void testAckIsReceivedOnFailedPublishMessage(String deviceName1, byte[] payload1, String deviceName2, byte[] payload2) throws Exception { + updateDefaultTenantProfileConfig(profileConfiguration -> { + profileConfiguration.setMaxDevices(3); + }); + + MqttTestClient client = new MqttTestClient(); + client.connectAndWait(gatewayAccessToken); + client.publishAndWait(GATEWAY_TELEMETRY_TOPIC, payload1); + + // check device is created + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertNotNull(doGet("/api/tenant/devices?deviceName=" + deviceName1, Device.class)); + }); + + client.publishAndWait(GATEWAY_TELEMETRY_TOPIC, payload2); + client.disconnectAndWait(); + + // check device was not created due to limit + doGet("/api/tenant/devices?deviceName=" + deviceName2).andExpect(status().isNotFound()); + + updateDefaultTenantProfileConfig(profileConfiguration -> { + profileConfiguration.setMaxDevices(0); + }); + } + @Test public void testGatewayConnect() throws Exception { String payload = "{\"device\":\"Device A\"}"; diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java index a23506a160..92b35d896c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java @@ -186,4 +186,15 @@ public abstract class AbstractMqttTimeseriesJsonIntegrationTest extends Abstract assertFalse(callback.isPubAckReceived()); } + @Override + public void testAckIsReceivedOnFailedPublishMessage() throws Exception { + MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder() + .deviceName("Test Post Telemetry device json payload") + .gatewayName("Test Post Telemetry gateway json payload") + .transportPayloadType(TransportPayloadType.JSON) + .telemetryTopicFilter(POST_DATA_TELEMETRY_TOPIC) + .build(); + processBeforeTest(configProperties); + super.testAckIsReceivedOnFailedPublishMessage(); + } } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java index 1b6c247d96..60e5857c04 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java @@ -459,6 +459,31 @@ public abstract class AbstractMqttTimeseriesProtoIntegrationTest extends Abstrac assertFalse(callback.isPubAckReceived()); } + @Override + public void testAckIsReceivedOnFailedPublishMessage() throws Exception { + MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder() + .deviceName("Test Post Telemetry device proto payload") + .gatewayName("Test Post Telemetry gateway proto payload") + .transportPayloadType(TransportPayloadType.PROTOBUF) + .telemetryTopicFilter(POST_DATA_TELEMETRY_TOPIC) + .build(); + processBeforeTest(configProperties); + + TransportApiProtos.GatewayTelemetryMsg.Builder gatewayTelemetryMsgProtoBuilder = TransportApiProtos.GatewayTelemetryMsg.newBuilder(); + List expectedKeys = Arrays.asList("key1", "key2", "key3", "key4", "key5"); + String deviceName1 = "Device A"; + String deviceName2 = "Device B"; + TransportApiProtos.TelemetryMsg deviceATelemetryMsgProto = getDeviceTelemetryMsgProto(deviceName1, expectedKeys, 10000, 20000); + gatewayTelemetryMsgProtoBuilder.addAllMsg(List.of(deviceATelemetryMsgProto)); + TransportApiProtos.GatewayTelemetryMsg payload1 = gatewayTelemetryMsgProtoBuilder.build(); + + TransportApiProtos.TelemetryMsg deviceBTelemetryMsgProto = getDeviceTelemetryMsgProto(deviceName2, expectedKeys, 10000, 20000); + TransportApiProtos.GatewayTelemetryMsg payload2 = TransportApiProtos.GatewayTelemetryMsg.newBuilder() + .addAllMsg(List.of(deviceBTelemetryMsgProto)) + .build(); + super.testAckIsReceivedOnFailedPublishMessage(deviceName1, payload1.toByteArray(), deviceName2, payload2.toByteArray()); + } + private DynamicSchema getDynamicSchema() { DeviceProfileTransportConfiguration transportConfiguration = deviceProfile.getProfileData().getTransportConfiguration(); assertTrue(transportConfiguration instanceof MqttDeviceProfileTransportConfiguration); diff --git a/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java index 2697b2b804..64b2fb032e 100644 --- a/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java +++ b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java @@ -26,14 +26,18 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.JsonDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState; import java.util.LinkedHashMap; import java.util.List; @@ -43,6 +47,8 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto; @ExtendWith(MockitoExtension.class) @@ -85,17 +91,23 @@ class CalculatedFieldUtilsTest { geofencingArgumentEntry.setZoneStates(zoneStates); // Create cf state with the geofencing argument and add it to the state map - CalculatedFieldState state = new GeofencingCalculatedFieldState(List.of("geofencingArgumentTest")); - state.updateState(mock(CalculatedFieldCtx.class), Map.of("geofencingArgumentTest", geofencingArgumentEntry)); + CalculatedFieldState state = new GeofencingCalculatedFieldState(DEVICE_ID); + + CalculatedFieldCtx cfCtxMock = mock(CalculatedFieldCtx.class); + when(cfCtxMock.getArgNames()).thenReturn(List.of("geofencingArgumentTest")); + + state.setCtx(cfCtxMock, null); + + Map updatedArguments = state.update(Map.of("geofencingArgumentTest", geofencingArgumentEntry), cfCtxMock); + assertThat(updatedArguments).hasSize(1); + assertThat(updatedArguments.get("geofencingArgumentTest")).isEqualTo(geofencingArgumentEntry); - // when CalculatedFieldStateProto proto = toProto(stateId, state); + CalculatedFieldState fromProto = CalculatedFieldUtils.fromProto(stateId, proto); - // then - CalculatedFieldState fromProto = CalculatedFieldUtils.fromProto(proto); assertThat(fromProto) .usingRecursiveComparison() - .ignoringFields("requiredArguments") + .ignoringFields("ctx", "requiredArguments", "readinessStatus") .isEqualTo(state); ArgumentEntry fromProtoArgument = fromProto.getArguments().get("geofencingArgumentTest"); @@ -106,4 +118,50 @@ class CalculatedFieldUtilsTest { assertThat(fromProtoGeoArgument.getZoneStates().get(z2).getLastPresence()).isNull(); } + @Test + void toProtoAndFromProto_shouldCreatePropagationStateWithoutPropagationArgument() { + // given + CalculatedFieldEntityCtxId stateId = mock(CalculatedFieldEntityCtxId.class); + given(stateId.tenantId()).willReturn(TENANT_ID); + given(stateId.cfId()).willReturn(CF_ID); + given(stateId.entityId()).willReturn(DEVICE_ID); + + AssetId propagationAssetId = new AssetId(UUID.fromString("17bbf99c-3b87-4d21-b07d-da7409bb2bb7")); + PropagationArgumentEntry propagationArgumentEntry = new PropagationArgumentEntry(List.of(propagationAssetId)); + + long lastUpdateTs = System.currentTimeMillis(); + SingleValueArgumentEntry singleValueArgumentEntry = new SingleValueArgumentEntry(new BaseAttributeKvEntry(new StringDataEntry("state", "active"), lastUpdateTs, 1L)); + + CalculatedFieldCtx cfCtxMock = mock(CalculatedFieldCtx.class); + when(cfCtxMock.getArgNames()).thenReturn(List.of("state")); + + CalculatedFieldState state = new PropagationCalculatedFieldState(DEVICE_ID); + + state.setCtx(cfCtxMock, null); + + Map updatedArguments = state.update(Map.of(PROPAGATION_CONFIG_ARGUMENT, propagationArgumentEntry, "state", singleValueArgumentEntry), cfCtxMock); + assertThat(updatedArguments).hasSize(2); + assertThat(updatedArguments.get(PROPAGATION_CONFIG_ARGUMENT)).isEqualTo(propagationArgumentEntry); + assertThat(updatedArguments.get("state")).isEqualTo(singleValueArgumentEntry); + + // when + CalculatedFieldStateProto proto = toProto(stateId, state); + + // then + CalculatedFieldState restored = CalculatedFieldUtils.fromProto(stateId, proto); + + // Propagation argument is not persisted -> should be absent after restore + assertThat(restored).isNotNull(); + assertThat(restored).isInstanceOf(PropagationCalculatedFieldState.class); + + PropagationCalculatedFieldState propagationState = (PropagationCalculatedFieldState) restored; + + assertThat(propagationState.getEntityId()).isEqualTo(DEVICE_ID); + assertThat(propagationState.getArguments()).isNotNull(); + assertThat(propagationState.getArguments().get(PROPAGATION_CONFIG_ARGUMENT)).isNull(); + assertThat(propagationState.getArguments().get("state")).isNotNull().isEqualTo(singleValueArgumentEntry); + assertThat(propagationState.getRequiredArguments()).isNull(); + assertThat(propagationState.getReadinessStatus()).isNull(); + } + } diff --git a/application/src/test/resources/lwm2m/3.xml b/application/src/test/resources/lwm2m/3-1_2.xml similarity index 100% rename from application/src/test/resources/lwm2m/3.xml rename to application/src/test/resources/lwm2m/3-1_2.xml diff --git a/common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java b/common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java index 1805788007..6ce3d5bde9 100644 --- a/common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java +++ b/common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.cluster; import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.TbResourceInfo; @@ -29,6 +30,7 @@ import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.ToDeviceActorNotificationMsg; import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; @@ -130,8 +132,14 @@ public interface TbClusterService extends TbQueueClusterService { void sendNotificationMsgToEdge(TenantId tenantId, EdgeId edgeId, EntityId entityId, String body, EdgeEventType type, EdgeEventActionType action, EdgeId sourceEdgeId); + void onCustomerUpdated(Customer customer, Customer oldCustomer); + void onCalculatedFieldUpdated(CalculatedField calculatedField, CalculatedField oldCalculatedField, TbQueueCallback callback); void onCalculatedFieldDeleted(CalculatedField calculatedField, TbQueueCallback callback); + void onRelationUpdated(TenantId tenantId, EntityRelation entityRelation, TbQueueCallback callback); + + void onRelationDeleted(TenantId tenantId, EntityRelation entityRelation, TbQueueCallback callback); + } diff --git a/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueConsumer.java b/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueConsumer.java index f9483965cc..3e1462b445 100644 --- a/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueConsumer.java +++ b/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueConsumer.java @@ -38,6 +38,8 @@ public interface TbQueueConsumer { boolean isStopped(); + Set getPartitions(); + List getFullTopicNames(); } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java index 3ad12048cf..55abc80998 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java @@ -29,6 +29,8 @@ public interface AiModelService extends EntityDaoService { AiModel save(AiModel model); + AiModel save(AiModel model, boolean doValidate); + Optional findAiModelById(TenantId tenantId, AiModelId modelId); PageData findAiModelsByTenantId(TenantId tenantId, PageLink pageLink); @@ -37,6 +39,8 @@ public interface AiModelService extends EntityDaoService { FluentFuture> findAiModelByTenantIdAndIdAsync(TenantId tenantId, AiModelId modelId); + Optional findAiModelByTenantIdAndName(TenantId tenantId, String name); + boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId); } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java index e26955d465..05abc4b0c7 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java @@ -51,10 +51,6 @@ import java.util.UUID; public interface AlarmService extends EntityDaoService { - /* - * New API, since 3.5. - */ - /** * Designed for atomic operations over active alarms. * Only one active alarm may exist for the pair {originatorId, alarmType} @@ -74,7 +70,7 @@ public interface AlarmService extends EntityDaoService { AlarmApiCallResult acknowledgeAlarm(TenantId tenantId, AlarmId alarmId, long ackTs); - AlarmApiCallResult clearAlarm(TenantId tenantId, AlarmId alarmId, long clearTs, JsonNode details); + AlarmApiCallResult clearAlarm(TenantId tenantId, AlarmId alarmId, long clearTs, JsonNode details, boolean pushEvent); AlarmApiCallResult assignAlarm(TenantId tenantId, AlarmId alarmId, UserId assigneeId, long ts); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java index c22c9c4140..59c17fb4bd 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.asset; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; @@ -48,6 +49,8 @@ public interface AssetService extends EntityDaoService { Asset saveAsset(Asset asset); + Asset saveAsset(Asset asset, NameConflictStrategy nameConflictStrategy); + Asset assignAssetToCustomer(TenantId tenantId, AssetId assetId, CustomerId customerId); Asset unassignAssetFromCustomer(TenantId tenantId, AssetId assetId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java index 85cd8d24fd..bef4675e03 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java @@ -16,9 +16,8 @@ package org.thingsboard.server.dao.cf; import org.thingsboard.server.common.data.cf.CalculatedField; -import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.id.CalculatedFieldId; -import org.thingsboard.server.common.data.id.CalculatedFieldLinkId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -35,7 +34,7 @@ public interface CalculatedFieldService extends EntityDaoService { CalculatedField findById(TenantId tenantId, CalculatedFieldId calculatedFieldId); - CalculatedField findByEntityIdAndName(EntityId entityId, String name); + CalculatedField findByEntityIdAndTypeAndName(EntityId entityId, CalculatedFieldType type, String name); List findCalculatedFieldIdsByEntityId(TenantId tenantId, EntityId entityId); @@ -45,24 +44,12 @@ public interface CalculatedFieldService extends EntityDaoService { PageData findCalculatedFieldsByTenantId(TenantId tenantId, PageLink pageLink); - PageData findAllCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId, PageLink pageLink); + PageData findCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId, CalculatedFieldType type, PageLink pageLink); void deleteCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId); int deleteAllCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId); - CalculatedFieldLink saveCalculatedFieldLink(TenantId tenantId, CalculatedFieldLink calculatedFieldLink); - - CalculatedFieldLink findCalculatedFieldLinkById(TenantId tenantId, CalculatedFieldLinkId calculatedFieldLinkId); - - List findAllCalculatedFieldLinksById(TenantId tenantId, CalculatedFieldId calculatedFieldId); - - List findAllCalculatedFieldLinksByEntityId(TenantId tenantId, EntityId entityId); - - PageData findAllCalculatedFieldLinksByTenantId(TenantId tenantId, PageLink pageLink); - - PageData findAllCalculatedFieldLinks(PageLink pageLink); - boolean referencedInAnyCalculatedField(TenantId tenantId, EntityId referencedEntityId); } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java index d478e2099a..1cd5ff9ff1 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.customer; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -37,6 +38,8 @@ public interface CustomerService extends EntityDaoService { Customer saveCustomer(Customer customer); + Customer saveCustomer(Customer customer, NameConflictStrategy nameConflictStrategy); + void deleteCustomer(TenantId tenantId, CustomerId customerId); Customer findOrCreatePublicCustomer(TenantId tenantId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java index 9eb258f182..759ab3a708 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.device.DeviceSearchQuery; import org.thingsboard.server.common.data.id.CustomerId; @@ -58,8 +59,12 @@ public interface DeviceService extends EntityDaoService { Device saveDeviceWithAccessToken(Device device, String accessToken); + Device saveDeviceWithAccessToken(Device device, String accessToken, NameConflictStrategy nameConflictStrategy); + Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials); + Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, NameConflictStrategy nameConflictStrategy); + Device saveDevice(ProvisionRequest provisionRequest, DeviceProfile profile); Device assignDeviceToCustomer(TenantId tenantId, DeviceId deviceId, CustomerId customerId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityDaoService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityDaoService.java index e9350842f8..dca2e81f29 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityDaoService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityDaoService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.entity; +import com.google.common.util.concurrent.FluentFuture; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; @@ -26,6 +27,8 @@ public interface EntityDaoService { Optional> findEntity(TenantId tenantId, EntityId entityId); + FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId); + default long countByTenantId(TenantId tenantId) { throw new IllegalArgumentException("Not implemented for " + getEntityType()); } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java index 9adf703e0b..0e41e0b031 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.entity; +import com.google.common.util.concurrent.FluentFuture; import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; @@ -38,6 +39,8 @@ public interface EntityService { Optional fetchEntityCustomerId(TenantId tenantId, EntityId entityId); + FluentFuture> fetchEntityCustomerIdAsync(TenantId tenantId, EntityId entityId); + Optional> fetchEntity(TenantId tenantId, EntityId entityId); Map fetchEntityInfos(TenantId tenantId, CustomerId customerId, Set entityIds); @@ -47,4 +50,5 @@ public interface EntityService { long countEntitiesByQuery(TenantId tenantId, CustomerId customerId, EntityCountQuery query); PageData findEntityDataByQuery(TenantId tenantId, CustomerId customerId, EntityDataQuery query); + } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java index 6e557106f8..a1bf54b1ee 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java @@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EdgeId; @@ -31,13 +32,12 @@ import org.thingsboard.server.dao.entity.EntityDaoService; import java.util.List; -/** - * Created by Victor Basanets on 8/27/2017. - */ public interface EntityViewService extends EntityDaoService { EntityView saveEntityView(EntityView entityView); + EntityView saveEntityView(EntityView entityView, NameConflictStrategy nameConflictStrategy); + EntityView saveEntityView(EntityView entityView, boolean doValidate); EntityView assignEntityViewToCustomer(TenantId tenantId, EntityViewId entityViewId, CustomerId customerId); @@ -52,6 +52,8 @@ public interface EntityViewService extends EntityDaoService { EntityView findEntityViewById(TenantId tenantId, EntityViewId entityViewId, boolean putInCache); + ListenableFuture findEntityViewByIdAsync(TenantId tenantId, EntityViewId entityViewId); + EntityView findEntityViewByTenantIdAndName(TenantId tenantId, String name); ListenableFuture findEntityViewByTenantIdAndNameAsync(TenantId tenantId, String name); @@ -74,8 +76,6 @@ public interface EntityViewService extends EntityDaoService { ListenableFuture> findEntityViewsByQuery(TenantId tenantId, EntityViewSearchQuery query); - ListenableFuture findEntityViewByIdAsync(TenantId tenantId, EntityViewId entityViewId); - ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(TenantId tenantId, EntityId entityId); List findEntityViewsByTenantIdAndEntityId(TenantId tenantId, EntityId entityId); @@ -95,4 +95,5 @@ public interface EntityViewService extends EntityDaoService { PageData findEntityViewsByTenantIdAndEdgeId(TenantId tenantId, EdgeId edgeId, PageLink pageLink); PageData findEntityViewsByTenantIdAndEdgeIdAndType(TenantId tenantId, EdgeId edgeId, String type, PageLink pageLink); + } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java index 1db5739e94..aaa52f99a7 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java @@ -20,11 +20,13 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntityRelationInfo; +import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; import org.thingsboard.server.common.data.relation.EntityRelationsQuery; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.rule.RuleChainType; import java.util.List; +import java.util.function.Predicate; /** * Created by ashvayka on 27.04.17. @@ -83,6 +85,12 @@ public interface RelationService { List findRuleNodeToRuleChainRelations(TenantId tenantId, RuleChainType ruleChainType, int limit); + ListenableFuture> findByRelationPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery); + + ListenableFuture> findFilteredRelationsByPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery, Predicate relationFilter); + + List findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery relationPathQuery); + // TODO: This method may be useful for some validations in the future // ListenableFuture checkRecursiveRelation(EntityId from, EntityId to); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java index ee187db46b..65211ec17a 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java @@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.ResourceExportData; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.TbResourceDeleteResult; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.TbResourceInfoFilter; @@ -46,6 +47,8 @@ public interface ResourceService extends EntityDaoService { byte[] getResourceData(TenantId tenantId, TbResourceId resourceId); + TbResourceDataInfo getResourceDataInfo(TenantId tenantId, TbResourceId resourceId); + ResourceExportData exportResource(TbResourceInfo resourceInfo); List exportResources(TenantId tenantId, Collection resources); @@ -90,4 +93,6 @@ public interface ResourceService extends EntityDaoService { TbResource createOrUpdateSystemResource(ResourceType resourceType, ResourceSubType resourceSubType, String resourceKey, byte[] data); + List findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds); + } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java new file mode 100644 index 0000000000..23485684af --- /dev/null +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.resource; + +import com.google.common.util.concurrent.FluentFuture; +import org.thingsboard.server.common.data.TbResourceDataInfo; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.id.TenantId; + +public interface TbResourceDataCache { + + FluentFuture getResourceDataInfoAsync(TenantId tenantId, TbResourceId resourceId); + + void evictResourceData(TenantId tenantId, TbResourceId resourceId); +} diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TbTenantProfileCache.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TbTenantProfileCache.java index 8acfbca542..273b52810a 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TbTenantProfileCache.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TbTenantProfileCache.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.tenant; -import org.thingsboard.server.common.data.SystemParams; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java index 1c8943ad7a..212e1e031e 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java @@ -23,6 +23,8 @@ import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.id.UserCredentialsId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.mobile.MobileSessionInfo; +import org.thingsboard.server.common.data.notification.targets.platform.SystemLevelUsersFilter; +import org.thingsboard.server.common.data.notification.targets.platform.UsersFilter; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.UserCredentials; @@ -116,4 +118,9 @@ public interface UserService extends EntityDaoService { void removeMobileSession(TenantId tenantId, String mobileToken); + int countTenantAdmins(TenantId tenantId); + + PageData findUsersByFilter(TenantId tenantId, UsersFilter filter, PageLink pageLink); + + boolean matchesFilter(TenantId tenantId, SystemLevelUsersFilter filter, User user); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Device.java b/common/data/src/main/java/org/thingsboard/server/common/data/Device.java index 57a0d24466..e170dbc467 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/Device.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/Device.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.device.data.DeviceData; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.OtaPackageId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.validation.Length; @@ -142,6 +143,11 @@ public class Device extends BaseDataWithAdditionalInfo implements HasL this.customerId = customerId; } + @JsonIgnore + public EntityId getOwnerId() { + return customerId != null && !customerId.isNullUid() ? customerId : tenantId; + } + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Unique Device Name in scope of Tenant", example = "A4B72CCDFF33") @Override public String getName() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java index 110052b57f..6a37f9fe1b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java @@ -23,6 +23,7 @@ import java.util.EnumSet; import java.util.List; public enum EntityType { + TENANT(1), CUSTOMER(2), USER(3, "tb_user"), @@ -61,7 +62,7 @@ public enum EntityType { MOBILE_APP(37), MOBILE_APP_BUNDLE(38), CALCULATED_FIELD(39), - CALCULATED_FIELD_LINK(40), + // CALCULATED_FIELD_LINK(40), - was removed in 4.3 JOB(41), ADMIN_SETTINGS(42), AI_MODEL(43, "ai_model") { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java new file mode 100644 index 0000000000..94edd4fa01 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@EqualsAndHashCode +@AllArgsConstructor +@NoArgsConstructor +public class GeneralFileDescriptor { + private String mediaType; +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/HasCustomerId.java b/common/data/src/main/java/org/thingsboard/server/common/data/HasCustomerId.java index 6a5501840d..d7900e96ed 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/HasCustomerId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/HasCustomerId.java @@ -20,4 +20,5 @@ import org.thingsboard.server.common.data.id.CustomerId; public interface HasCustomerId { CustomerId getCustomerId(); + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmStateUpdateResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictPolicy.java similarity index 82% rename from rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmStateUpdateResult.java rename to common/data/src/main/java/org/thingsboard/server/common/data/NameConflictPolicy.java index ba7dc5dce8..1685dbc933 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmStateUpdateResult.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictPolicy.java @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.rule.engine.profile; +package org.thingsboard.server.common.data; -enum AlarmStateUpdateResult { +public enum NameConflictPolicy { - NONE, CREATED, UPDATED, SEVERITY_UPDATED, CLEARED; + FAIL, + UNIQUIFY; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java new file mode 100644 index 0000000000..9624b8c978 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema +public record NameConflictStrategy(NameConflictPolicy policy, String separator, UniquifyStrategy uniquifyStrategy) { + + public static final NameConflictStrategy DEFAULT = new NameConflictStrategy(NameConflictPolicy.FAIL, null, null); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ProfileEntityIdInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/ProfileEntityIdInfo.java index 22934de813..1fb49a46f7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ProfileEntityIdInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ProfileEntityIdInfo.java @@ -34,21 +34,23 @@ public class ProfileEntityIdInfo implements Serializable, HasTenantId { private static final long serialVersionUID = 8532058281983868003L; private final TenantId tenantId; + private final EntityId ownerId; private final EntityId profileId; private final EntityId entityId; - private ProfileEntityIdInfo(UUID tenantId, EntityId profileId, EntityId entityId) { + private ProfileEntityIdInfo(UUID tenantId, EntityId ownerId, EntityId profileId, EntityId entityId) { this.tenantId = TenantId.fromUUID(tenantId); + this.ownerId = ownerId; this.profileId = profileId; this.entityId = entityId; } - public static ProfileEntityIdInfo create(UUID tenantId, DeviceProfileId profileId, DeviceId entityId) { - return new ProfileEntityIdInfo(tenantId, profileId, entityId); + public static ProfileEntityIdInfo create(UUID tenantId, EntityId ownerId, DeviceProfileId profileId, DeviceId entityId) { + return new ProfileEntityIdInfo(tenantId, ownerId, profileId, entityId); } - public static ProfileEntityIdInfo create(UUID tenantId, AssetProfileId profileId, AssetId entityId) { - return new ProfileEntityIdInfo(tenantId, profileId, entityId); + public static ProfileEntityIdInfo create(UUID tenantId, EntityId ownerId, AssetProfileId profileId, AssetId entityId) { + return new ProfileEntityIdInfo(tenantId, ownerId, profileId, entityId); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java index 77b17198e9..f7579b6878 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java @@ -25,7 +25,8 @@ public enum ResourceType { PKCS_12("application/x-pkcs12", false, false), JS_MODULE("application/javascript", true, true), IMAGE(null, true, true), - DASHBOARD("application/json", true, true); + DASHBOARD("application/json", true, true), + GENERAL(null, false, true); @Getter private final String mediaType; 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 f83a812529..6a475daae3 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 @@ -40,5 +40,6 @@ public class SystemParams { long maxDataPointsPerRollingArg; int minAllowedScheduledUpdateIntervalInSecForCF; int maxRelationLevelPerCfArgument; + long minAllowedDeduplicationIntervalInSecForCF; TrendzSettings trendzSettings; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java index ba37067106..457d30e263 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data; import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonSetter; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -86,6 +87,11 @@ public class TbResource extends TbResourceInfo { .orElse(null); } + @JsonIgnore + public TbResourceDataInfo toResourceDataInfo() { + return new TbResourceDataInfo(data, getDescriptor()); + } + @Override public String toString() { return super.toString(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java new file mode 100644 index 0000000000..039478470d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class TbResourceDataInfo { + + private byte[] data; + private JsonNode descriptor; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java index edc5a2f539..76945a97ed 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java @@ -17,7 +17,6 @@ package org.thingsboard.server.common.data; import lombok.Builder; import lombok.Data; -import org.thingsboard.server.common.data.id.HasId; import java.util.List; import java.util.Map; @@ -27,6 +26,6 @@ import java.util.Map; public class TbResourceDeleteResult { private boolean success; - private Map>> references; + private Map> references; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/UniquifyStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/UniquifyStrategy.java new file mode 100644 index 0000000000..5c9841f096 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/UniquifyStrategy.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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; + +public enum UniquifyStrategy { + + RANDOM, + INCREMENTAL; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java index 2cc17e4553..73e6557fb5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java @@ -22,7 +22,7 @@ import io.swagger.v3.oas.annotations.media.Schema; @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, property = "status", - include = JsonTypeInfo.As.PROPERTY, + include = JsonTypeInfo.As.EXISTING_PROPERTY, visible = true ) @JsonSubTypes({ @@ -51,9 +51,7 @@ public sealed interface TbChatResponse permits TbChatResponse.Success, TbChatRes } record Failure( - @Schema( - description = "A string containing details about the failure" - ) + @Schema(description = "A string containing details about the failure") String errorDetails ) implements TbChatResponse { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java index bfaa29a6e3..f18429e7cf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelCon import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; @@ -34,6 +35,7 @@ import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; @JsonTypeInfo( @@ -50,7 +52,8 @@ import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; @JsonSubTypes.Type(value = MistralAiChatModelConfig.class, name = "MISTRAL_AI"), @JsonSubTypes.Type(value = AnthropicChatModelConfig.class, name = "ANTHROPIC"), @JsonSubTypes.Type(value = AmazonBedrockChatModelConfig.class, name = "AMAZON_BEDROCK"), - @JsonSubTypes.Type(value = GitHubModelsChatModelConfig.class, name = "GITHUB_MODELS") + @JsonSubTypes.Type(value = GitHubModelsChatModelConfig.class, name = "GITHUB_MODELS"), + @JsonSubTypes.Type(value = OllamaChatModelConfig.class, name = "OLLAMA") }) public interface AiModelConfig { @@ -69,7 +72,8 @@ public interface AiModelConfig { @JsonSubTypes.Type(value = MistralAiProviderConfig.class, name = "MISTRAL_AI"), @JsonSubTypes.Type(value = AnthropicProviderConfig.class, name = "ANTHROPIC"), @JsonSubTypes.Type(value = AmazonBedrockProviderConfig.class, name = "AMAZON_BEDROCK"), - @JsonSubTypes.Type(value = GitHubModelsProviderConfig.class, name = "GITHUB_MODELS") + @JsonSubTypes.Type(value = GitHubModelsProviderConfig.class, name = "GITHUB_MODELS"), + @JsonSubTypes.Type(value = OllamaProviderConfig.class, name = "OLLAMA") }) AiProviderConfig providerConfig(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java index 2bc28cfce0..49126c1861 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java @@ -24,7 +24,7 @@ public sealed interface AiChatModelConfig> extend permits OpenAiChatModelConfig, AzureOpenAiChatModelConfig, GoogleAiGeminiChatModelConfig, GoogleVertexAiGeminiChatModelConfig, MistralAiChatModelConfig, AnthropicChatModelConfig, - AmazonBedrockChatModelConfig, GitHubModelsChatModelConfig { + AmazonBedrockChatModelConfig, GitHubModelsChatModelConfig, OllamaChatModelConfig { ChatModel configure(Langchain4jChatModelConfigurer configurer); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java index 2bb4de5aa8..d2ab72086a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java @@ -33,7 +33,7 @@ public record AmazonBedrockChatModelConfig( @NotBlank String modelId, @PositiveOrZero Double temperature, @Positive @Max(1) Double topP, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java index 69b5578fb3..6d505f75a6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java @@ -34,7 +34,7 @@ public record AnthropicChatModelConfig( @PositiveOrZero Double temperature, @Positive @Max(1) Double topP, @PositiveOrZero Integer topK, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java index 47e7e96c37..f70f2af539 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java @@ -35,7 +35,7 @@ public record AzureOpenAiChatModelConfig( @Positive @Max(1) Double topP, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java index b509254f77..0aafd72197 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java @@ -35,7 +35,7 @@ public record GitHubModelsChatModelConfig( @Positive @Max(1) Double topP, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java index fe11a11460..b5c3d4263d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java @@ -36,7 +36,7 @@ public record GoogleAiGeminiChatModelConfig( @PositiveOrZero Integer topK, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java index 609e14f86e..944963ee27 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java @@ -36,7 +36,7 @@ public record GoogleVertexAiGeminiChatModelConfig( @PositiveOrZero Integer topK, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java index c9c1bc3173..828256dcdc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java @@ -35,4 +35,6 @@ public interface Langchain4jChatModelConfigurer { ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig); + ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig); + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java index f603e99c53..8f67d93398 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java @@ -35,7 +35,7 @@ public record MistralAiChatModelConfig( @Positive @Max(1) Double topP, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java new file mode 100644 index 0000000000..ea48670b63 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; + +@Builder +public record OllamaChatModelConfig( + @NotNull @Valid OllamaProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + @PositiveOrZero Integer topK, + Integer contextLength, + Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.OLLAMA; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return true; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java index 00b5115d7d..23db9accc2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java @@ -35,7 +35,7 @@ public record OpenAiChatModelConfig( @Positive @Max(1) Double topP, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java index d0a5bd0510..a9a6af4de8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java @@ -24,6 +24,7 @@ public enum AiProvider { MISTRAL_AI, ANTHROPIC, AMAZON_BEDROCK, - GITHUB_MODELS + GITHUB_MODELS, + OLLAMA } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java index bd32c88efb..5423b24410 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java @@ -19,4 +19,4 @@ public sealed interface AiProviderConfig permits OpenAiProviderConfig, AzureOpenAiProviderConfig, GoogleAiGeminiProviderConfig, GoogleVertexAiGeminiProviderConfig, MistralAiProviderConfig, AnthropicProviderConfig, - AmazonBedrockProviderConfig, GitHubModelsProviderConfig {} + AmazonBedrockProviderConfig, GitHubModelsProviderConfig, OllamaProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java new file mode 100644 index 0000000000..39bb57834c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.ai.provider; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +public record OllamaProviderConfig( + @NotNull String baseUrl, + @NotNull @Valid OllamaAuth auth +) implements AiProviderConfig { + + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" + ) + @JsonSubTypes({ + @JsonSubTypes.Type(value = OllamaAuth.None.class, name = "NONE"), + @JsonSubTypes.Type(value = OllamaAuth.Basic.class, name = "BASIC"), + @JsonSubTypes.Type(value = OllamaAuth.Token.class, name = "TOKEN") + }) + public sealed interface OllamaAuth { + + record None() implements OllamaAuth {} + + record Basic(@NotNull String username, @NotNull String password) implements OllamaAuth {} + + record Token(@NotNull String token) implements OllamaAuth {} + + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java index 09ffda837b..f7864db6e3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java @@ -15,8 +15,32 @@ */ package org.thingsboard.server.common.data.ai.provider; -import jakarta.validation.constraints.NotNull; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.constraints.AssertTrue; +import lombok.Builder; +import org.apache.commons.lang3.StringUtils; +import java.util.Objects; + +@Builder public record OpenAiProviderConfig( - @NotNull String apiKey -) implements AiProviderConfig {} + String baseUrl, + String apiKey +) implements AiProviderConfig { + + public static final String OPENAI_OFFICIAL_BASE_URL = "https://api.openai.com/v1"; + + public OpenAiProviderConfig { + baseUrl = Objects.requireNonNullElse(baseUrl, OPENAI_OFFICIAL_BASE_URL); + } + + @JsonIgnore + @AssertTrue(message = "API key is required when using the official OpenAI API") + public boolean isValid() { + if (baseUrl.equals(OPENAI_OFFICIAL_BASE_URL)) { + return StringUtils.isNotBlank(apiKey); + } + return true; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmInfo.java index 7a80a09891..70032327b9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmInfo.java @@ -21,11 +21,14 @@ import lombok.Getter; import lombok.Setter; import lombok.ToString; +import java.io.Serial; + @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) @Schema public class AlarmInfo extends Alarm { + @Serial private static final long serialVersionUID = 2807343093519543363L; @Getter @@ -38,6 +41,11 @@ public class AlarmInfo extends Alarm { @Schema(description = "Alarm originator label", example = "Thermostat label") private String originatorLabel; + @Getter + @Setter + @Schema(description = "Originator display name", example = "Thermostat") + private String originatorDisplayName; + @Getter @Setter @Schema(description = "Alarm assignee") @@ -53,8 +61,8 @@ public class AlarmInfo extends Alarm { public AlarmInfo(AlarmInfo alarmInfo) { super(alarmInfo); - this.originatorName = alarmInfo.originatorName; - this.originatorLabel = alarmInfo.originatorLabel; + this.originatorName = alarmInfo.getOriginatorName(); + this.originatorLabel = alarmInfo.getOriginatorLabel(); this.assignee = alarmInfo.getAssignee(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java new file mode 100644 index 0000000000..9a4e875154 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.thingsboard.server.common.data.alarm.rule.condition.AlarmCondition; +import org.thingsboard.server.common.data.id.DashboardId; + +@Data +public class AlarmRule { + + @Valid + @NotNull + private AlarmCondition condition; + private String alarmDetails; + private DashboardId dashboardId; + + @JsonIgnore + public boolean requiresScheduledReevaluation() { + return condition.hasSchedule(); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java new file mode 100644 index 0000000000..9bb549994b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java @@ -0,0 +1,56 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import jakarta.validation.Valid; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; +import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AlarmSchedule; +import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AnyTimeSchedule; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @Type(name = "SIMPLE", value = SimpleAlarmCondition.class), + @Type(name = "DURATION", value = DurationAlarmCondition.class), + @Type(name = "REPEATING", value = RepeatingAlarmCondition.class), +}) +@Data +@NoArgsConstructor +public abstract class AlarmCondition { + + @NotNull + @Valid + private AlarmConditionExpression expression; + @Valid + private AlarmConditionValue schedule; + + @JsonIgnore + public boolean hasSchedule() { + return schedule != null && !(schedule.getStaticValue() instanceof AnyTimeSchedule); + } + + @JsonIgnore + public abstract AlarmConditionType getType(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmConditionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmConditionType.java new file mode 100644 index 0000000000..fd98ed2984 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmConditionType.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition; + +public enum AlarmConditionType { + SIMPLE, + DURATION, + REPEATING +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmConditionValue.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmConditionValue.java new file mode 100644 index 0000000000..fab3a78ab3 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmConditionValue.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.constraints.AssertTrue; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AlarmConditionValue { + + private T staticValue; + private String dynamicValueArgument; + + @JsonIgnore + @AssertTrue(message = "Either staticValue or dynamicValueArgument must be set") + public boolean isValid() { + return staticValue != null ^ dynamicValueArgument != null; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/DurationAlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/DurationAlarmCondition.java new file mode 100644 index 0000000000..22733ab78d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/DurationAlarmCondition.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.util.concurrent.TimeUnit; + +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class DurationAlarmCondition extends AlarmCondition { + + @NotNull + private TimeUnit unit; + @Valid + @NotNull + private AlarmConditionValue value; + + @Override + public AlarmConditionType getType() { + return AlarmConditionType.DURATION; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/RepeatingAlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/RepeatingAlarmCondition.java new file mode 100644 index 0000000000..7919a6a22a --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/RepeatingAlarmCondition.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class RepeatingAlarmCondition extends AlarmCondition { + + @Valid + @NotNull + private AlarmConditionValue count; + + @Override + public AlarmConditionType getType() { + return AlarmConditionType.REPEATING; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/SimpleAlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/SimpleAlarmCondition.java new file mode 100644 index 0000000000..8e2a7593b0 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/SimpleAlarmCondition.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition; + +public class SimpleAlarmCondition extends AlarmCondition { + + @Override + public AlarmConditionType getType() { + return AlarmConditionType.SIMPLE; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpression.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpression.java new file mode 100644 index 0000000000..e855f8efd3 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpression.java @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.expression; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @Type(name = "SIMPLE", value = SimpleAlarmConditionExpression.class), + @Type(name = "TBEL", value = TbelAlarmConditionExpression.class), +}) +public interface AlarmConditionExpression { + + @JsonIgnore + AlarmConditionExpressionType getType(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpressionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpressionType.java new file mode 100644 index 0000000000..f0b8f5253d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpressionType.java @@ -0,0 +1,21 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.expression; + +public enum AlarmConditionExpressionType { + SIMPLE, + TBEL +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java new file mode 100644 index 0000000000..e99849ea82 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.expression; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.KeyFilterPredicate; +import org.thingsboard.server.common.data.query.EntityKeyValueType; + +import java.io.Serializable; +import java.util.List; + +@Data +public class AlarmConditionFilter implements Serializable { + + @NotBlank + private String argument; + @NotNull + private EntityKeyValueType valueType; + private ComplexOperation operation; + @Valid + @NotNull + private List predicates; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java new file mode 100644 index 0000000000..21c28fa552 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java @@ -0,0 +1,21 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.expression; + +public enum ComplexOperation { + AND, + OR +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java new file mode 100644 index 0000000000..8c27400961 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.expression; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SimpleAlarmConditionExpression implements AlarmConditionExpression { + + @Valid + @NotEmpty + private List filters; + private ComplexOperation operation; + + @Override + public AlarmConditionExpressionType getType() { + return AlarmConditionExpressionType.SIMPLE; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/TbelAlarmConditionExpression.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/TbelAlarmConditionExpression.java new file mode 100644 index 0000000000..50f73e887b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/TbelAlarmConditionExpression.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.expression; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class TbelAlarmConditionExpression implements AlarmConditionExpression { + + @NotBlank + private String expression; + + @Override + public AlarmConditionExpressionType getType() { + return AlarmConditionExpressionType.TBEL; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/BooleanFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/BooleanFilterPredicate.java new file mode 100644 index 0000000000..94dced5fe4 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/BooleanFilterPredicate.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.expression.predicate; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; + +@Data +public class BooleanFilterPredicate implements SimpleKeyFilterPredicate { + + @NotNull + private BooleanOperation operation; + @Valid + @NotNull + private AlarmConditionValue value; + + @Override + public FilterPredicateType getType() { + return FilterPredicateType.BOOLEAN; + } + + public enum BooleanOperation { + EQUAL, + NOT_EQUAL + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java new file mode 100644 index 0000000000..4e24ea28ba --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.expression.predicate; + +import lombok.Data; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; + +import java.util.List; + +@Data +public class ComplexFilterPredicate implements KeyFilterPredicate { + + private ComplexOperation operation; + private List predicates; + + @Override + public FilterPredicateType getType() { + return FilterPredicateType.COMPLEX; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/FilterPredicateType.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/FilterPredicateType.java new file mode 100644 index 0000000000..af7c45ac5b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/FilterPredicateType.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.expression.predicate; + +public enum FilterPredicateType { + STRING, + NUMERIC, + BOOLEAN, + COMPLEX +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/KeyFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/KeyFilterPredicate.java new file mode 100644 index 0000000000..58355c627d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/KeyFilterPredicate.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.expression.predicate; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import java.io.Serializable; + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @Type(value = StringFilterPredicate.class, name = "STRING"), + @Type(value = NumericFilterPredicate.class, name = "NUMERIC"), + @Type(value = BooleanFilterPredicate.class, name = "BOOLEAN"), + @Type(value = ComplexFilterPredicate.class, name = "COMPLEX")}) +public interface KeyFilterPredicate extends Serializable { + + @JsonIgnore + FilterPredicateType getType(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java new file mode 100644 index 0000000000..65316eda88 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.expression.predicate; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; + +@Data +public class NumericFilterPredicate implements SimpleKeyFilterPredicate { + + @NotNull + private NumericOperation operation; + @Valid + @NotNull + private AlarmConditionValue value; + + @Override + public FilterPredicateType getType() { + return FilterPredicateType.NUMERIC; + } + + public enum NumericOperation { + EQUAL, + NOT_EQUAL, + GREATER, + LESS, + GREATER_OR_EQUAL, + LESS_OR_EQUAL + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/SimpleKeyFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/SimpleKeyFilterPredicate.java new file mode 100644 index 0000000000..0ea4cbf1eb --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/SimpleKeyFilterPredicate.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.expression.predicate; + +import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; + +public interface SimpleKeyFilterPredicate extends KeyFilterPredicate { + + AlarmConditionValue getValue(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/StringFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/StringFilterPredicate.java new file mode 100644 index 0000000000..913c12ca1c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/StringFilterPredicate.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.expression.predicate; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; + +@Data +public class StringFilterPredicate implements SimpleKeyFilterPredicate { + + @NotNull + private StringOperation operation; + @Valid + @NotNull + private AlarmConditionValue value; + private boolean ignoreCase; + + @Override + public FilterPredicateType getType() { + return FilterPredicateType.STRING; + } + + public enum StringOperation { + EQUAL, + NOT_EQUAL, + STARTS_WITH, + ENDS_WITH, + CONTAINS, + NOT_CONTAINS, + IN, + NOT_IN + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmSchedule.java new file mode 100644 index 0000000000..e7394c94bd --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmSchedule.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.schedule; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import java.io.Serializable; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @Type(value = AnyTimeSchedule.class, name = "ANY_TIME"), + @Type(value = SpecificTimeSchedule.class, name = "SPECIFIC_TIME"), + @Type(value = CustomTimeSchedule.class, name = "CUSTOM") +}) +public interface AlarmSchedule extends Serializable { + + @JsonIgnore + AlarmScheduleType getType(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmScheduleType.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmScheduleType.java new file mode 100644 index 0000000000..d18d92834e --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmScheduleType.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.schedule; + +public enum AlarmScheduleType { + ANY_TIME, + SPECIFIC_TIME, + CUSTOM +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AnyTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AnyTimeSchedule.java new file mode 100644 index 0000000000..e84f767f5b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AnyTimeSchedule.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.schedule; + +public class AnyTimeSchedule implements AlarmSchedule { + + @Override + public AlarmScheduleType getType() { + return AlarmScheduleType.ANY_TIME; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeSchedule.java new file mode 100644 index 0000000000..b084494d28 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeSchedule.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.schedule; + +import lombok.Data; + +import java.util.List; + +@Data +public class CustomTimeSchedule implements AlarmSchedule { + + private String timezone; + private List items; + + @Override + public AlarmScheduleType getType() { + return AlarmScheduleType.CUSTOM; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeScheduleItem.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeScheduleItem.java new file mode 100644 index 0000000000..8a2bb97c39 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeScheduleItem.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.schedule; + +import lombok.Data; + +import java.io.Serializable; + +@Data +public class CustomTimeScheduleItem implements Serializable { + + private boolean enabled; + private int dayOfWeek; + private long startsOn; + private long endsOn; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/SpecificTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/SpecificTimeSchedule.java new file mode 100644 index 0000000000..7242d2c9cd --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/SpecificTimeSchedule.java @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.alarm.rule.condition.schedule; + +import lombok.Data; + +import java.util.Set; + +@Data +public class SpecificTimeSchedule implements AlarmSchedule { + + private String timezone; + private Set daysOfWeek; + private long startsOn; + private long endsOn; + + @Override + public AlarmScheduleType getType() { + return AlarmScheduleType.SPECIFIC_TIME; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java b/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java index e732049118..a34b58a4da 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.asset; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; import lombok.EqualsAndHashCode; @@ -29,6 +30,7 @@ import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; @@ -125,6 +127,11 @@ public class Asset extends BaseDataWithAdditionalInfo implements HasLab this.customerId = customerId; } + @JsonIgnore + public EntityId getOwnerId() { + return customerId != null && !customerId.isNullUid() ? customerId : tenantId; + } + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Unique Asset Name in scope of Tenant", example = "Empire State Building") @Override public String getName() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java index 3b2ddf0627..8c5adefcf8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java @@ -18,11 +18,14 @@ package org.thingsboard.server.common.data.cf; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonSetter; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.HasDebugSettings; import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.HasTenantId; @@ -37,6 +40,10 @@ import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; import java.io.Serial; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; @Schema @Data @@ -46,6 +53,22 @@ public class CalculatedField extends BaseData implements HasN @Serial private static final long serialVersionUID = 4491966747773381420L; + public static final Map> SUPPORTED_ENTITIES = Map.of( + EntityType.DEVICE, CalculatedFieldType.all, + EntityType.ASSET, CalculatedFieldType.all, + EntityType.DEVICE_PROFILE, CalculatedFieldType.all, + EntityType.ASSET_PROFILE, CalculatedFieldType.all, + EntityType.CUSTOMER, Set.of(CalculatedFieldType.ALARM) + ); + + public static final Set SUPPORTED_REFERENCED_ENTITIES = Collections.unmodifiableSet(EnumSet.of( + EntityType.DEVICE, EntityType.ASSET, EntityType.CUSTOMER, EntityType.TENANT + )); + + public static boolean isSupportedRefEntity(EntityId entity) { + return SUPPORTED_REFERENCED_ENTITIES.contains(entity.getEntityType()); + } + private TenantId tenantId; private EntityId entityId; @@ -64,6 +87,8 @@ public class CalculatedField extends BaseData implements HasN @Schema(description = "Version of calculated field configuration.", example = "0") private int configurationVersion; @Schema(implementation = SimpleCalculatedFieldConfiguration.class) + @Valid + @NotNull private CalculatedFieldConfiguration configuration; @Getter @Setter diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldLink.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldLink.java index 3f048815da..71f5917c34 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldLink.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldLink.java @@ -15,52 +15,8 @@ */ package org.thingsboard.server.common.data.cf; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; -import lombok.EqualsAndHashCode; -import org.thingsboard.server.common.data.BaseData; import org.thingsboard.server.common.data.id.CalculatedFieldId; -import org.thingsboard.server.common.data.id.CalculatedFieldLinkId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; -@Schema -@Data -@EqualsAndHashCode(callSuper = true) -public class CalculatedFieldLink extends BaseData { - - private static final long serialVersionUID = 6492846246722091530L; - - private TenantId tenantId; - private EntityId entityId; - - @Schema(description = "JSON object with the Calculated Field Id. ", accessMode = Schema.AccessMode.READ_ONLY) - private CalculatedFieldId calculatedFieldId; - - public CalculatedFieldLink() { - super(); - } - - public CalculatedFieldLink(CalculatedFieldLinkId id) { - super(id); - } - - public CalculatedFieldLink(TenantId tenantId, EntityId entityId, CalculatedFieldId calculatedFieldId) { - this.tenantId = tenantId; - this.entityId = entityId; - this.calculatedFieldId = calculatedFieldId; - } - - @Override - public String toString() { - return new StringBuilder() - .append("CalculatedFieldLink[") - .append("tenantId=").append(tenantId) - .append(", entityId=").append(entityId) - .append(", calculatedFieldId=").append(calculatedFieldId) - .append(", createdTime=").append(createdTime) - .append(", id=").append(id).append(']') - .toString(); - } - -} +public record CalculatedFieldLink(TenantId tenantId, EntityId entityId, CalculatedFieldId calculatedFieldId) {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java index d4dd2c5812..4463c835db 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java @@ -15,8 +15,19 @@ */ package org.thingsboard.server.common.data.cf; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; + public enum CalculatedFieldType { - SIMPLE, SCRIPT, GEOFENCING + SIMPLE, + SCRIPT, + GEOFENCING, + ALARM, + PROPAGATION, + RELATED_ENTITIES_AGGREGATION; + + public static final Set all = Collections.unmodifiableSet(EnumSet.allOf(CalculatedFieldType.class)); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java new file mode 100644 index 0000000000..d36ba33849 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java @@ -0,0 +1,90 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.configuration; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; +import org.apache.commons.lang3.tuple.Pair; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.alarm.rule.AlarmRule; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.util.CollectionsUtil; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.BiPredicate; +import java.util.stream.Stream; + +import static java.util.Map.Entry.comparingByKey; + +@Data +public class AlarmCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration { + + private Map arguments; + + @Valid + @NotEmpty + private Map createRules; + @Valid + private AlarmRule clearRule; + + private boolean propagate; + private boolean propagateToOwner; + private boolean propagateToTenant; + private List propagateRelationTypes; + + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.ALARM; + } + + @Override + public Output getOutput() { + return null; + } + + @JsonIgnore + @Override + public boolean requiresScheduledReevaluation() { + return getAllRules().anyMatch(entry -> entry.getValue().requiresScheduledReevaluation()); + } + + @JsonIgnore + public Stream> getAllRules() { + Stream> rules = createRules.entrySet().stream() + .map(entry -> Pair.of(entry.getKey(), entry.getValue())); + if (clearRule != null) { + rules = Stream.concat(rules, Stream.of(Pair.of(null, clearRule))); + } + return rules.sorted(comparingByKey(Comparator.nullsLast(Comparator.naturalOrder()))); + } + + public boolean rulesEqual(AlarmCalculatedFieldConfiguration other, BiPredicate equalityCheck) { + List> thisRules = this.getAllRules().toList(); + List> otherRules = other.getAllRules().toList(); + return CollectionsUtil.elementsEqual(thisRules, otherRules, (thisRule, otherRule) -> { + if (!Objects.equals(thisRule.getKey(), otherRule.getKey())) { + return false; + } + return equalityCheck.test(thisRule.getValue(), otherRule.getValue()); + }); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java index 52935c3411..8d4a831cf9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java @@ -37,4 +37,16 @@ public class Argument { return refDynamicSourceConfiguration != null; } + public boolean hasRelationQuerySource() { + return hasDynamicSource() && refDynamicSourceConfiguration.getType() == CFArgumentDynamicSourceType.RELATION_PATH_QUERY; + } + + public boolean hasOwnerSource() { + return hasDynamicSource() && refDynamicSourceConfiguration.getType() == CFArgumentDynamicSourceType.CURRENT_OWNER; + } + + public boolean hasTsRollingArgument() { + return ArgumentType.TS_ROLLING.equals(refEntityKey.getType()); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java index 225278e776..294335e665 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java @@ -15,10 +15,32 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.Collections; import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import static java.util.stream.Collectors.toSet; public interface ArgumentsBasedCalculatedFieldConfiguration extends CalculatedFieldConfiguration { + @Valid + @NotEmpty Map getArguments(); + default Set getReferencedEntities() { + var args = getArguments(); + if (args == null) { + return Collections.emptySet(); + } + return args.values().stream() + .map(Argument::getRefEntityId) + .filter(Objects::nonNull) + .collect(toSet()); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java index c270874605..6913b1ed63 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java @@ -16,15 +16,8 @@ package org.thingsboard.server.common.data.cf.configuration; import lombok.Data; -import org.thingsboard.server.common.data.cf.CalculatedFieldLink; -import org.thingsboard.server.common.data.id.CalculatedFieldId; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.id.TenantId; -import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.stream.Collectors; @Data public abstract class BaseCalculatedFieldConfiguration implements ExpressionBasedCalculatedFieldConfiguration { @@ -34,21 +27,17 @@ public abstract class BaseCalculatedFieldConfiguration implements ExpressionBase protected Output output; @Override - public List getReferencedEntities() { - return arguments.values().stream() - .map(Argument::getRefEntityId) - .filter(Objects::nonNull) - .collect(Collectors.toList()); + public void validate() { + baseCalculatedFieldRestriction(); + if (arguments.values().stream().anyMatch(Argument::hasRelationQuerySource)) { + throw new IllegalArgumentException("Calculated field with type: '" + getType() + "' doesn't support relation query configuration!"); + } } - @Override - public void validate() { + protected void baseCalculatedFieldRestriction() { if (arguments.containsKey("ctx")) { throw new IllegalArgumentException("Argument name 'ctx' is reserved and cannot be used."); } - if (arguments.values().stream().anyMatch(Argument::hasDynamicSource)) { - throw new IllegalArgumentException("Calculated field with type: '" + getType() + "' doesn't support dynamic source configuration!"); - } } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java index bd2e9b0c00..a9d374015b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.cf.configuration; public enum CFArgumentDynamicSourceType { - RELATION_QUERY + CURRENT_OWNER, + RELATION_PATH_QUERY } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java index 972a3e0ee9..5313dea0bf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java @@ -18,9 +18,11 @@ package org.thingsboard.server.common.data.cf.configuration; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; @@ -28,6 +30,7 @@ import org.thingsboard.server.common.data.id.TenantId; import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; @JsonTypeInfo( @@ -36,9 +39,12 @@ import java.util.stream.Collectors; property = "type" ) @JsonSubTypes({ - @JsonSubTypes.Type(value = SimpleCalculatedFieldConfiguration.class, name = "SIMPLE"), - @JsonSubTypes.Type(value = ScriptCalculatedFieldConfiguration.class, name = "SCRIPT"), - @JsonSubTypes.Type(value = GeofencingCalculatedFieldConfiguration.class, name = "GEOFENCING") + @Type(value = SimpleCalculatedFieldConfiguration.class, name = "SIMPLE"), + @Type(value = ScriptCalculatedFieldConfiguration.class, name = "SCRIPT"), + @Type(value = GeofencingCalculatedFieldConfiguration.class, name = "GEOFENCING"), + @Type(value = AlarmCalculatedFieldConfiguration.class, name = "ALARM"), + @Type(value = PropagationCalculatedFieldConfiguration.class, name = "PROPAGATION"), + @Type(value = RelatedEntitiesAggregationCalculatedFieldConfiguration.class, name = "RELATED_ENTITIES_AGGREGATION") }) @JsonIgnoreProperties(ignoreUnknown = true) public interface CalculatedFieldConfiguration { @@ -48,19 +54,15 @@ public interface CalculatedFieldConfiguration { Output getOutput(); - void validate(); + default void validate() {} @JsonIgnore - default List getReferencedEntities() { - return Collections.emptyList(); + default Set getReferencedEntities() { + return Collections.emptySet(); } default CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId) { - CalculatedFieldLink link = new CalculatedFieldLink(); - link.setTenantId(tenantId); - link.setEntityId(referencedEntityId); - link.setCalculatedFieldId(calculatedFieldId); - return link; + return new CalculatedFieldLink(tenantId, referencedEntityId, calculatedFieldId); } default List buildCalculatedFieldLinks(TenantId tenantId, EntityId cfEntityId, CalculatedFieldId calculatedFieldId) { @@ -70,4 +72,9 @@ public interface CalculatedFieldConfiguration { .collect(Collectors.toList()); } + @JsonIgnore + default boolean requiresScheduledReevaluation() { + return false; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java index f36071615e..6a0f0c25ca 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java @@ -26,7 +26,8 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; property = "type" ) @JsonSubTypes({ - @JsonSubTypes.Type(value = RelationQueryDynamicSourceConfiguration.class, name = "RELATION_QUERY") + @JsonSubTypes.Type(value = RelationPathQueryDynamicSourceConfiguration.class, name = "RELATION_PATH_QUERY"), + @JsonSubTypes.Type(value = CurrentOwnerDynamicSourceConfiguration.class, name = "CURRENT_OWNER") }) @JsonIgnoreProperties(ignoreUnknown = true) public interface CfArgumentDynamicSourceConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CurrentOwnerDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CurrentOwnerDynamicSourceConfiguration.java new file mode 100644 index 0000000000..be9a519f1f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CurrentOwnerDynamicSourceConfiguration.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.configuration; + +import lombok.Data; + +@Data +public class CurrentOwnerDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration { + + @Override + public CFArgumentDynamicSourceType getType() { + return CFArgumentDynamicSourceType.CURRENT_OWNER; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java new file mode 100644 index 0000000000..61d4542eb9 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java @@ -0,0 +1,94 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.configuration; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.relation.RelationPathLevel; + +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +public class PropagationCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration { + + public static final String PROPAGATION_CONFIG_ARGUMENT = "propagationCtx"; + + @Valid + @NotNull + private RelationPathLevel relation; + + private boolean applyExpressionToResolvedArguments; + + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.PROPAGATION; + } + + @Override + public void validate() { + baseCalculatedFieldRestriction(); + propagationRestriction(); + if (!applyExpressionToResolvedArguments) { + arguments.forEach((name, argument) -> { + if (!currentEntitySource(argument)) { + throw new IllegalArgumentException("Arguments in 'Arguments only' propagation mode support only the 'Current entity' source entity type!"); + } + if (argument.getRefEntityKey() == null) { + throw new IllegalArgumentException("Argument: '" + name + "' doesn't have reference entity key configured!"); + } + if (argument.getRefEntityKey().getType() == ArgumentType.TS_ROLLING) { + throw new IllegalArgumentException("Argument type: 'Time series rolling' detected for argument: '" + name + "'. " + + "Only 'Attribute' or 'Latest telemetry' arguments are allowed for 'Arguments only' propagation mode!"); + } + }); + } else { + boolean noneMatchCurrentEntitySource = arguments.entrySet() + .stream() + .noneMatch(entry -> currentEntitySource(entry.getValue())); + if (noneMatchCurrentEntitySource) { + throw new IllegalArgumentException("At least one argument must be configured with the 'Current entity' " + + "source entity type for 'Expression result' propagation mode!"); + } + if (StringUtils.isBlank(expression)) { + throw new IllegalArgumentException("Expression must be specified for 'Expression result' propagation mode!"); + } + } + } + + public Argument toPropagationArgument() { + var refDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + refDynamicSourceConfiguration.setLevels(List.of(relation)); + var propagationArgument = new Argument(); + propagationArgument.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration); + return propagationArgument; + } + + private void propagationRestriction() { + if (arguments.entrySet().stream().anyMatch(entry -> entry.getKey().equals(PROPAGATION_CONFIG_ARGUMENT))) { + throw new IllegalArgumentException("Argument name '" + PROPAGATION_CONFIG_ARGUMENT + "' is reserved and cannot be used."); + } + } + + private boolean currentEntitySource(Argument argument) { + return argument.getRefEntityId() == null && argument.getRefDynamicSourceConfiguration() == null; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java new file mode 100644 index 0000000000..dc92ff3685 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java @@ -0,0 +1,71 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.configuration; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; +import org.thingsboard.server.common.data.util.CollectionsUtil; + +import java.util.List; +import java.util.NoSuchElementException; + +@Data +public class RelationPathQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration { + + private List levels; + + @Override + public CFArgumentDynamicSourceType getType() { + return CFArgumentDynamicSourceType.RELATION_PATH_QUERY; + } + + @Override + public void validate() { + if (CollectionsUtil.isEmpty(levels)) { + throw new IllegalArgumentException("At least one relation level must be specified!"); + } + levels.forEach(RelationPathLevel::validate); + } + + public List resolveEntityIds(List relations) { + EntitySearchDirection lastLevelDirection = getLastLevel().direction(); + return switch (lastLevelDirection) { + case FROM -> relations.stream().map(EntityRelation::getTo).toList(); + case TO -> relations.stream().map(EntityRelation::getFrom).toList(); + }; + } + + public void validateMaxRelationLevel(String argumentName, int maxAllowedRelationLevel) { + if (levels.size() > maxAllowedRelationLevel) { + throw new IllegalArgumentException("Max relation level is greater than configured " + + "maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + argumentName); + } + } + + public EntityRelationPathQuery toRelationPathQuery(EntityId entityId) { + return new EntityRelationPathQuery(entityId, levels); + } + + private RelationPathLevel getLastLevel() { + return levels.get(levels.size() - 1); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java deleted file mode 100644 index 4e9b4252c9..0000000000 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Copyright © 2016-2025 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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.cf.configuration; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import lombok.Data; -import org.thingsboard.server.common.data.StringUtils; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.relation.EntityRelation; -import org.thingsboard.server.common.data.relation.EntityRelationsQuery; -import org.thingsboard.server.common.data.relation.EntitySearchDirection; -import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; -import org.thingsboard.server.common.data.relation.RelationsSearchParameters; - -import java.util.Collections; -import java.util.List; - -@Data -public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration { - - private int maxLevel; - private boolean fetchLastLevelOnly; - private EntitySearchDirection direction; - private String relationType; - - @Override - public CFArgumentDynamicSourceType getType() { - return CFArgumentDynamicSourceType.RELATION_QUERY; - } - - @Override - public void validate() { - if (maxLevel < 1) { - throw new IllegalArgumentException("Relation query dynamic source configuration max relation level can't be less than 1!"); - } - if (direction == null) { - throw new IllegalArgumentException("Relation query dynamic source configuration direction must be specified!"); - } - if (StringUtils.isBlank(relationType)) { - throw new IllegalArgumentException("Relation query dynamic source configuration relation type must be specified!"); - } - } - - @JsonIgnore - public boolean isSimpleRelation() { - return maxLevel == 1; - } - - public void validateMaxRelationLevel(String argumentName, int maxAllowedRelationLevel) { - if (maxLevel > maxAllowedRelationLevel) { - throw new IllegalArgumentException("Max relation level is greater than configured " + - "maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + argumentName); - } - } - - public EntityRelationsQuery toEntityRelationsQuery(EntityId rootEntityId) { - if (isSimpleRelation()) { - throw new IllegalArgumentException("Entity relations query can't be created for a simple relation!"); - } - var entityRelationsQuery = new EntityRelationsQuery(); - entityRelationsQuery.setParameters(new RelationsSearchParameters(rootEntityId, direction, maxLevel, fetchLastLevelOnly)); - entityRelationsQuery.setFilters(Collections.singletonList(new RelationEntityTypeFilter(relationType, Collections.emptyList()))); - return entityRelationsQuery; - } - - public List resolveEntityIds(List relations) { - return switch (direction) { - case FROM -> relations.stream().map(EntityRelation::getTo).toList(); - case TO -> relations.stream().map(EntityRelation::getFrom).toList(); - }; - } - -} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java index 7902a9cf5b..e1e8ca1a9b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java @@ -15,13 +15,13 @@ */ package org.thingsboard.server.common.data.cf.configuration; -import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.constraints.PositiveOrZero; public interface ScheduledUpdateSupportedCalculatedFieldConfiguration extends CalculatedFieldConfiguration { - @JsonIgnore boolean isScheduledUpdateEnabled(); + @PositiveOrZero int getScheduledUpdateInterval(); void setScheduledUpdateInterval(int interval); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggFunction.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggFunction.java new file mode 100644 index 0000000000..cd0e3f66d4 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggFunction.java @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.configuration.aggregation; + +public enum AggFunction { + MIN, MAX, SUM, AVG, COUNT, COUNT_UNIQUE +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggFunctionInput.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggFunctionInput.java new file mode 100644 index 0000000000..f6df80952e --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggFunctionInput.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.configuration.aggregation; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AggFunctionInput implements AggInput { + + private String function; + + @Override + public String getType() { + return "function"; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggInput.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggInput.java new file mode 100644 index 0000000000..06929de81c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggInput.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.configuration.aggregation; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = AggKeyInput.class, name = "key"), + @JsonSubTypes.Type(value = AggFunctionInput.class, name = "function") +}) +@JsonIgnoreProperties(ignoreUnknown = true) +public interface AggInput { + + @JsonIgnore + String getType(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggKeyInput.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggKeyInput.java new file mode 100644 index 0000000000..1a00a18a9d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggKeyInput.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.configuration.aggregation; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AggKeyInput implements AggInput { + + private String key; + + @Override + public String getType() { + return "key"; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggMetric.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggMetric.java new file mode 100644 index 0000000000..ebd612b1e0 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggMetric.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.configuration.aggregation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class AggMetric { + + private AggFunction function; + private String filter; + private AggInput input; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java new file mode 100644 index 0000000000..cf7040c4bb --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java @@ -0,0 +1,67 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.configuration.aggregation; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.relation.RelationPathLevel; + +import java.util.Map; + +@Data +public class RelatedEntitiesAggregationCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration, ScheduledUpdateSupportedCalculatedFieldConfiguration { + + @NotNull + private RelationPathLevel relation; + private Map arguments; + private long deduplicationIntervalInSec; + @Valid + @NotEmpty + private Map metrics; + private Output output; + private boolean useLatestTs; + + private int scheduledUpdateInterval; + + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.RELATED_ENTITIES_AGGREGATION; + } + + @Override + public boolean isScheduledUpdateEnabled() { + return true; + } + + @Override + public void validate() { + relation.validate(); + if (arguments.containsKey("ctx")) { + throw new IllegalArgumentException("Argument name 'ctx' is reserved and cannot be used."); + } + if (arguments.values().stream().anyMatch(Argument::hasTsRollingArgument)) { + throw new IllegalArgumentException("Calculated field with type: '" + getType() + "' doesn't support TS_ROLLING arguments."); + } + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java index 9ea5c19e8c..ad31293061 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java @@ -16,8 +16,8 @@ package org.thingsboard.server.common.data.cf.configuration.geofencing; +import jakarta.validation.constraints.NotBlank; import lombok.Data; -import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; @@ -30,18 +30,11 @@ public class EntityCoordinates { public static final String ENTITY_ID_LATITUDE_ARGUMENT_KEY = "latitude"; public static final String ENTITY_ID_LONGITUDE_ARGUMENT_KEY = "longitude"; + @NotBlank private final String latitudeKeyName; + @NotBlank private final String longitudeKeyName; - public void validate() { - if (StringUtils.isBlank(latitudeKeyName)) { - throw new IllegalArgumentException("Entity coordinates latitude key name must be specified!"); - } - if (StringUtils.isBlank(longitudeKeyName)) { - throw new IllegalArgumentException("Entity coordinates longitude key name must be specified!"); - } - } - public Map toArguments() { return Map.of( ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument(latitudeKeyName), diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java index dc331f5876..acefd5ff26 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java @@ -16,6 +16,8 @@ package org.thingsboard.server.common.data.cf.configuration.geofencing; import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import lombok.Data; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; @@ -24,16 +26,26 @@ import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.EntityId; +import java.util.Collections; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; + +import static java.util.stream.Collectors.toSet; @Data public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration, ScheduledUpdateSupportedCalculatedFieldConfiguration { + @Valid + @NotNull private EntityCoordinates entityCoordinates; + + @Valid + @NotNull private Map zoneGroups; + + private boolean scheduledUpdateEnabled; private int scheduledUpdateInterval; private Output output; @@ -51,9 +63,13 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal return args; } + @Override - public List getReferencedEntities() { - return zoneGroups.values().stream().map(ZoneGroupConfiguration::getRefEntityId).filter(Objects::nonNull).toList(); + public Set getReferencedEntities() { + return zoneGroups == null ? Collections.emptySet() : zoneGroups.values().stream() + .map(ZoneGroupConfiguration::getRefEntityId) + .filter(Objects::nonNull) + .collect(toSet()); } @Override @@ -61,20 +77,8 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal return output; } - @Override - public boolean isScheduledUpdateEnabled() { - return scheduledUpdateInterval > 0 && zoneGroups.values().stream().anyMatch(ZoneGroupConfiguration::hasDynamicSource); - } - @Override public void validate() { - if (entityCoordinates == null) { - throw new IllegalArgumentException("Geofencing calculated field entity coordinates must be specified!"); - } - entityCoordinates.validate(); - if (zoneGroups == null || zoneGroups.isEmpty()) { - throw new IllegalArgumentException("Geofencing calculated field must contain at least one geofencing zone group defined!"); - } zoneGroups.forEach((key, value) -> value.validate(key)); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java index 2feb6e49d0..a06cb242cf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java @@ -17,6 +17,8 @@ package org.thingsboard.server.common.data.cf.configuration.geofencing; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Data; import org.springframework.lang.Nullable; import org.thingsboard.server.common.data.AttributeScope; @@ -36,8 +38,10 @@ public class ZoneGroupConfiguration { private EntityId refEntityId; private CfArgumentDynamicSourceConfiguration refDynamicSourceConfiguration; + @NotBlank private final String perimeterKeyName; + @NotNull private final GeofencingReportStrategy reportStrategy; private final boolean createRelationsWithMatchedZones; @@ -48,13 +52,7 @@ public class ZoneGroupConfiguration { if (EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY.equals(name) || EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY.equals(name)) { throw new IllegalArgumentException("Name '" + name + "' is reserved and cannot be used for zone group!"); } - if (StringUtils.isBlank(perimeterKeyName)) { - throw new IllegalArgumentException("Perimeter key name must be specified for '" + name + "' zone group!"); - } - if (reportStrategy == null) { - throw new IllegalArgumentException("Report strategy must be specified for '" + name + "' zone group!"); - } - if (hasDynamicSource()) { + if (refDynamicSourceConfiguration != null) { refDynamicSourceConfiguration.validate(); } if (!createRelationsWithMatchedZones) { @@ -68,8 +66,12 @@ public class ZoneGroupConfiguration { } } - public boolean hasDynamicSource() { - return refDynamicSourceConfiguration != null; + public boolean hasRelationQuerySource() { + return toArgument().hasRelationQuerySource(); + } + + public boolean hasCurrentOwnerSource() { + return toArgument().hasOwnerSource(); } @JsonIgnore @@ -92,4 +94,5 @@ public class ZoneGroupConfiguration { argument.setRefEntityKey(new ReferencedEntityKey(perimeterKeyName, ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); return argument; } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/Lwm2mServerIdentifier.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/Lwm2mServerIdentifier.java new file mode 100644 index 0000000000..f4f020776b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/credentials/lwm2m/Lwm2mServerIdentifier.java @@ -0,0 +1,112 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.device.credentials.lwm2m; + +/** + * Enum representing predefined LwM2M Short Server Identifiers. + *

+ * See OMA Lightweight M2M Specification for details about the server identifier space. + */ +public enum Lwm2mServerIdentifier { + + /** + * Not used for identifying an LwM2M Server (0). + */ + NOT_USED_IDENTIFYING_LWM2M_SERVER_MIN(0, "Bootstrap Short Server ID", false), + + /** + * Primary LwM2M Server Short Server ID (1). + * Upper boundary for valid LwM2M Server Identifiers (1–65534). + */ + PRIMARY_LWM2M_SERVER(1, "LwM2M Server Short Server ID", true), + + /** + * Maximum valid LwM2M Server ID (65534). + * Upper boundary for valid LwM2M Server Identifiers (1–65534). + */ + LWM2M_SERVER_MAX(65534, "LwM2M Server Short Server ID", true), + + /** + * Not used for identifying an LwM2M Server (65535). + * Reserved sentinel value representing "no server associated" or "invalid ID". + * MUST NOT be assigned to any LwM2M Server according to OMA-TS-LightweightM2M-Core, §6.2.1. + * OMA LwM2M Core / v1.2: Server / Short Server ID): «MAX_ID 65535 is a reserved value and MUST NOT be used for identifying an Object» + */ + NOT_USED_IDENTIFYING_LWM2M_SERVER_MAX(65535, "Reserved sentinel value (no active server)", false); + + private final Integer id; + private final String description; + private final boolean isLwm2mServer; + + Lwm2mServerIdentifier(Integer id, String description, boolean isLwm2mServer) { + this.id = id; + this.description = description; + this.isLwm2mServer = isLwm2mServer; + } + + /** + * @return the integer value of this Short Server ID. + */ + public Integer getId() { + return id; + } + + /** + * @return a human-readable description of this Server ID. + */ + public String getDescription() { + return description; + } + + /** + * @return true if this ID represents a Lwm2m Server. + */ + public boolean isLwm2mServer() { + return isLwm2mServer; + } + + /** + * Checks whether a given ID represents a valid LwM2M Server (1–65534). + * @param id Short Server ID value. + * @return true if the ID belongs to a standard LwM2M Server. + */ + public static boolean isLwm2mServer(Integer id) { + return id != null && id >= PRIMARY_LWM2M_SERVER.id && id <= LWM2M_SERVER_MAX.id; + } + public static boolean isNotLwm2mServer(Integer id) { + return id == null || id < PRIMARY_LWM2M_SERVER.id || id > LWM2M_SERVER_MAX.id; + } + + /** + * Returns a {@link Lwm2mServerIdentifier} instance matching the given ID. + * @param id numeric ID. + * @return corresponding enum constant. + * @throws IllegalArgumentException if no constant matches the given ID. + */ + public static Lwm2mServerIdentifier fromId(Integer id) { + for (Lwm2mServerIdentifier s : values()) { + if (s.id == id) { + return s; + } + } + throw new IllegalArgumentException("Unknown Lwm2mServerIdentifier: " + id); + } + + @Override + public String toString() { + return name() + "(" + id + ") - " + description; + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java index 07c42eb31b..f840886834 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java @@ -26,6 +26,7 @@ import java.util.List; @Schema @Data @JsonIgnoreProperties(ignoreUnknown = true) +@Deprecated public class AlarmCondition implements Serializable { @Valid diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java index 210193fc01..6e7e6ab321 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java @@ -26,6 +26,7 @@ import java.io.Serializable; @Schema @Data +@Deprecated public class AlarmConditionFilter implements Serializable { @Valid diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilterKey.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilterKey.java index d31e6710ef..3c6e5252a0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilterKey.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilterKey.java @@ -23,6 +23,7 @@ import java.io.Serializable; @Schema @Data +@Deprecated public class AlarmConditionFilterKey implements Serializable { @Schema(description = "The key type", example = "TIME_SERIES") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionKeyType.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionKeyType.java index 9eef80e312..6f451a1abc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionKeyType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionKeyType.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.device.profile; +@Deprecated public enum AlarmConditionKeyType { ATTRIBUTE, TIME_SERIES, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpec.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpec.java index 37b2a9d7c5..f3f969f641 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpec.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpec.java @@ -31,6 +31,7 @@ import java.io.Serializable; @JsonSubTypes.Type(value = SimpleAlarmConditionSpec.class, name = "SIMPLE"), @JsonSubTypes.Type(value = DurationAlarmConditionSpec.class, name = "DURATION"), @JsonSubTypes.Type(value = RepeatingAlarmConditionSpec.class, name = "REPEATING")}) +@Deprecated public interface AlarmConditionSpec extends Serializable { @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpecType.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpecType.java index adef445914..229be24b42 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpecType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpecType.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.device.profile; +@Deprecated public enum AlarmConditionSpecType { SIMPLE, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java index 16850e3669..633cde766e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java @@ -21,12 +21,17 @@ import lombok.Data; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.validation.NoXss; +import java.io.Serial; import java.io.Serializable; @Schema @Data +@Deprecated public class AlarmRule implements Serializable { + @Serial + private static final long serialVersionUID = -7617427132423304707L; + @Valid @Schema(description = "JSON object representing the alarm rule condition") private AlarmCondition condition; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java index 09e8d3c146..4bfa2ef9f6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java @@ -31,6 +31,7 @@ import java.io.Serializable; @JsonSubTypes.Type(value = AnyTimeSchedule.class, name = "ANY_TIME"), @JsonSubTypes.Type(value = SpecificTimeSchedule.class, name = "SPECIFIC_TIME"), @JsonSubTypes.Type(value = CustomTimeSchedule.class, name = "CUSTOM")}) +@Deprecated public interface AlarmSchedule extends Serializable { AlarmScheduleType getType(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmScheduleType.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmScheduleType.java index f50a3b47db..ab06cb9335 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmScheduleType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmScheduleType.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.device.profile; +@Deprecated public enum AlarmScheduleType { ANY_TIME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java index 426430481a..87766d685c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.device.profile; import org.thingsboard.server.common.data.query.DynamicValue; +@Deprecated public class AnyTimeSchedule implements AlarmSchedule { @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java index b372a2fa07..6b07341b30 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java @@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.query.DynamicValue; import java.util.List; @Data +@Deprecated public class CustomTimeSchedule implements AlarmSchedule { private String timezone; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeScheduleItem.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeScheduleItem.java index abcbec4e32..b0781e3ad1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeScheduleItem.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeScheduleItem.java @@ -20,6 +20,7 @@ import lombok.Data; import java.io.Serializable; @Data +@Deprecated public class CustomTimeScheduleItem implements Serializable { private boolean enabled; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileAlarm.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileAlarm.java index fb8488c58e..fcbece4d4b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileAlarm.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileAlarm.java @@ -28,6 +28,7 @@ import java.util.TreeMap; @Schema @Data +@Deprecated public class DeviceProfileAlarm implements Serializable { @Schema(description = "String value representing the alarm rule id", example = "highTemperatureAlarmID") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DurationAlarmConditionSpec.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DurationAlarmConditionSpec.java index e114ec1ddc..361ba1e4b3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DurationAlarmConditionSpec.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DurationAlarmConditionSpec.java @@ -23,6 +23,7 @@ import java.util.concurrent.TimeUnit; @Data @JsonIgnoreProperties(ignoreUnknown = true) +@Deprecated public class DurationAlarmConditionSpec implements AlarmConditionSpec { private TimeUnit unit; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/RepeatingAlarmConditionSpec.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/RepeatingAlarmConditionSpec.java index f9e3fd6d05..75c07dbe02 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/RepeatingAlarmConditionSpec.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/RepeatingAlarmConditionSpec.java @@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.query.FilterPredicateValue; @Data @JsonIgnoreProperties(ignoreUnknown = true) +@Deprecated public class RepeatingAlarmConditionSpec implements AlarmConditionSpec { private FilterPredicateValue predicate; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SimpleAlarmConditionSpec.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SimpleAlarmConditionSpec.java index 05c8d0df70..4243946a30 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SimpleAlarmConditionSpec.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SimpleAlarmConditionSpec.java @@ -20,6 +20,7 @@ import lombok.Data; @Data @JsonIgnoreProperties(ignoreUnknown = true) +@Deprecated public class SimpleAlarmConditionSpec implements AlarmConditionSpec { @Override public AlarmConditionSpecType getType() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java index e46d5edbf3..a8b47db1ab 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java @@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.query.DynamicValue; import java.util.Set; @Data +@Deprecated public class SpecificTimeSchedule implements AlarmSchedule { private String timezone; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/TelemetryMappingConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/TelemetryMappingConfiguration.java index 1669e1a101..bdf47a9e9c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/TelemetryMappingConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/TelemetryMappingConfiguration.java @@ -36,6 +36,7 @@ public class TelemetryMappingConfiguration implements Serializable { private Set attribute; private Set telemetry; private Map attributeLwm2m; + private Boolean initAttrTelAsObsStrategy; private TelemetryObserveStrategy observeStrategy; @JsonCreator @@ -45,6 +46,7 @@ public class TelemetryMappingConfiguration implements Serializable { @JsonProperty("attribute") Set attribute, @JsonProperty("telemetry") Set telemetry, @JsonProperty("attributeLwm2m") Map attributeLwm2m, + @JsonProperty("initAttrTelAsObsStrategy") Boolean initAttrTelAsObsStrategy, @JsonProperty("observeStrategy") TelemetryObserveStrategy observeStrategy) { this.keyName = keyName != null ? keyName : Collections.emptyMap(); @@ -52,6 +54,7 @@ public class TelemetryMappingConfiguration implements Serializable { this.attribute = attribute != null ? attribute : Collections.emptySet(); this.telemetry = telemetry != null ? telemetry : Collections.emptySet(); this.attributeLwm2m = attributeLwm2m != null ? attributeLwm2m : Collections.emptyMap(); + this.initAttrTelAsObsStrategy = initAttrTelAsObsStrategy != null ? initAttrTelAsObsStrategy : false; this.observeStrategy = observeStrategy != null ? observeStrategy : TelemetryObserveStrategy.SINGLE; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/bootstrap/LwM2MServerSecurityConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/bootstrap/LwM2MServerSecurityConfig.java index 29c130b902..e5eb4be902 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/bootstrap/LwM2MServerSecurityConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/bootstrap/LwM2MServerSecurityConfig.java @@ -26,7 +26,7 @@ public class LwM2MServerSecurityConfig implements Serializable { @Schema(description = "Server short Id. Used as link to associate server Object Instance. This identifier uniquely identifies each LwM2M Server configured for the LwM2M Client. " + "This Resource MUST be set when the Bootstrap-Server Resource has a value of 'false'. " + - "The values ID:1 and ID:65534 values MUST NOT be used for identifying the LwM2M Server.", example = "123", accessMode = Schema.AccessMode.READ_ONLY) + "The values ID:0 and ID:65535 values MUST NOT be used for identifying the LwM2M Server.", example = "123", accessMode = Schema.AccessMode.READ_ONLY) protected Integer shortServerId = 123; /** Security -> ObjectId = 0 'LWM2M Security' */ @Schema(description = "Is Bootstrap Server or Lwm2m Server. " + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java index 0d5c3f34ad..fadb44207a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java @@ -47,7 +47,8 @@ public enum EdgeEventType { TB_RESOURCE(true, EntityType.TB_RESOURCE), OAUTH2_CLIENT(true, EntityType.OAUTH2_CLIENT), DOMAIN(true, EntityType.DOMAIN), - CALCULATED_FIELD(false, EntityType.CALCULATED_FIELD); + CALCULATED_FIELD(false, EntityType.CALCULATED_FIELD), + AI_MODEL(true, EntityType.AI_MODEL); private final boolean allEdgesRelated; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/UserFields.java b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/UserFields.java index 6f4c7643c0..31e0f4118c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/UserFields.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edqs/fields/UserFields.java @@ -37,7 +37,7 @@ public class UserFields extends AbstractEntityFields { @Override public String getName() { - return super.getEmail(); + return getEmail(); } public UserFields(UUID id, long createdTime, UUID tenantId, UUID customerId, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/CalculatedFieldDebugEvent.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/CalculatedFieldDebugEvent.java index 0424eabeb6..acc5cf6205 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/CalculatedFieldDebugEvent.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/CalculatedFieldDebugEvent.java @@ -29,7 +29,7 @@ import org.thingsboard.server.common.data.id.TenantId; import java.util.UUID; -@ToString +@ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) public class CalculatedFieldDebugEvent extends Event { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java index fc1c1cd1a9..3b8959624d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java @@ -80,7 +80,6 @@ public class EntityIdFactory { case DOMAIN -> new DomainId(uuid); case MOBILE_APP_BUNDLE -> new MobileAppBundleId(uuid); case CALCULATED_FIELD -> new CalculatedFieldId(uuid); - case CALCULATED_FIELD_LINK -> new CalculatedFieldLinkId(uuid); case JOB -> new JobId(uuid); case ADMIN_SETTINGS -> new AdminSettingsId(uuid); case AI_MODEL -> new AiModelId(uuid); @@ -113,6 +112,7 @@ public class EntityIdFactory { case OAUTH2_CLIENT -> new OAuth2ClientId(uuid); case DOMAIN -> new DomainId(uuid); case CALCULATED_FIELD -> new CalculatedFieldId(uuid); + case AI_MODEL -> new AiModelId(uuid); default -> throw new IllegalArgumentException("EdgeEventType " + edgeEventType + " is not supported!"); }; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/JobResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobResult.java index bd2c73d0d8..6f6d924deb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/job/JobResult.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobResult.java @@ -55,11 +55,17 @@ public abstract class JobResult implements Serializable { public void processTaskResult(TaskResult taskResult) { if (taskResult.isSuccess()) { - successfulCount++; + if (totalCount == null || successfulCount < totalCount) { + successfulCount++; + } } else if (taskResult.isDiscarded()) { - discardedCount++; + if (totalCount == null || discardedCount < totalCount) { + discardedCount++; + } } else { - failedCount++; + if (totalCount == null || failedCount < totalCount) { + failedCount++; + } if (results.size() < 100) { // preserving only first 100 errors, not reprocessing if there are more failures results.add(taskResult); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/bundle/MobileAppBundle.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/bundle/MobileAppBundle.java index 2190972d7e..b023ba89c3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/bundle/MobileAppBundle.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/bundle/MobileAppBundle.java @@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.id.MobileAppId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.mobile.layout.MobileLayoutConfig; import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoXss; @EqualsAndHashCode(callSuper = true) @Data @@ -40,9 +41,11 @@ public class MobileAppBundle extends BaseData implements HasT private TenantId tenantId; @Schema(description = "Application bundle title. Cannot be empty", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank + @NoXss @Length(fieldName = "title") private String title; @Schema(description = "Application bundle description.") + @NoXss @Length(fieldName = "description") private String description; @Schema(description = "Android application id") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/msg/TbMsgType.java b/common/data/src/main/java/org/thingsboard/server/common/data/msg/TbMsgType.java index f942bc2196..720dc5b790 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/msg/TbMsgType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/msg/TbMsgType.java @@ -38,7 +38,10 @@ public enum TbMsgType { ENTITY_UNASSIGNED("Entity Unassigned"), ATTRIBUTES_UPDATED("Attributes Updated"), ATTRIBUTES_DELETED("Attributes Deleted"), - ALARM, + ALARM("Alarm"), + ALARM_CREATED("Alarm Created"), + ALARM_UPDATED("Alarm Updated"), + ALARM_SEVERITY_UPDATED("Alarm Severity Updated"), ALARM_ACK("Alarm Acknowledged"), ALARM_CLEAR("Alarm Cleared"), ALARM_DELETE, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/AlarmAssignmentNotificationInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/AlarmAssignmentNotificationInfo.java index f29a54d17a..086b326ddd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/AlarmAssignmentNotificationInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/AlarmAssignmentNotificationInfo.java @@ -53,6 +53,7 @@ public class AlarmAssignmentNotificationInfo implements RuleOriginatedNotificati private UUID alarmId; private EntityId alarmOriginator; private String alarmOriginatorName; + private String alarmOriginatorLabel; private AlarmSeverity alarmSeverity; private AlarmStatus alarmStatus; private CustomerId alarmCustomerId; @@ -77,7 +78,8 @@ public class AlarmAssignmentNotificationInfo implements RuleOriginatedNotificati "alarmStatus", alarmStatus.toString(), "alarmOriginatorEntityType", alarmOriginator.getEntityType().getNormalName(), "alarmOriginatorId", alarmOriginator.getId().toString(), - "alarmOriginatorName", alarmOriginatorName + "alarmOriginatorName", alarmOriginatorName, + "alarmOriginatorLabel", alarmOriginatorLabel ); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/AlarmCommentNotificationInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/AlarmCommentNotificationInfo.java index e46d539399..202812921f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/AlarmCommentNotificationInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/AlarmCommentNotificationInfo.java @@ -48,6 +48,7 @@ public class AlarmCommentNotificationInfo implements RuleOriginatedNotificationI private UUID alarmId; private EntityId alarmOriginator; private String alarmOriginatorName; + private String alarmOriginatorLabel; private AlarmSeverity alarmSeverity; private AlarmStatus alarmStatus; private CustomerId alarmCustomerId; @@ -68,7 +69,8 @@ public class AlarmCommentNotificationInfo implements RuleOriginatedNotificationI "alarmStatus", alarmStatus.toString(), "alarmOriginatorEntityType", alarmOriginator.getEntityType().getNormalName(), "alarmOriginatorId", alarmOriginator.getId().toString(), - "alarmOriginatorName", alarmOriginatorName + "alarmOriginatorName", alarmOriginatorName, + "alarmOriginatorLabel", alarmOriginatorLabel ); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/AlarmNotificationInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/AlarmNotificationInfo.java index a1a9a34a36..5ad33b1e6f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/AlarmNotificationInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/AlarmNotificationInfo.java @@ -25,11 +25,10 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.EntityId; +import java.util.HashMap; import java.util.Map; import java.util.UUID; -import static org.thingsboard.server.common.data.util.CollectionsUtil.mapOf; - @Data @NoArgsConstructor @AllArgsConstructor @@ -41,25 +40,28 @@ public class AlarmNotificationInfo implements RuleOriginatedNotificationInfo { private UUID alarmId; private EntityId alarmOriginator; private String alarmOriginatorName; + private String alarmOriginatorLabel; private AlarmSeverity alarmSeverity; private AlarmStatus alarmStatus; private boolean acknowledged; private boolean cleared; private CustomerId alarmCustomerId; private DashboardId dashboardId; + private Map details; @Override public Map getTemplateData() { - return mapOf( - "alarmType", alarmType, - "action", action, - "alarmId", alarmId.toString(), - "alarmSeverity", alarmSeverity.name().toLowerCase(), - "alarmStatus", alarmStatus.toString(), - "alarmOriginatorEntityType", alarmOriginator.getEntityType().getNormalName(), - "alarmOriginatorName", alarmOriginatorName, - "alarmOriginatorId", alarmOriginator.getId().toString() - ); + Map templateData = details != null ? new HashMap<>(details) : new HashMap<>(); + templateData.put("alarmType", alarmType); + templateData.put("action", action); + templateData.put("alarmId", alarmId.toString()); + templateData.put("alarmSeverity", alarmSeverity.name().toLowerCase()); + templateData.put("alarmStatus", alarmStatus.toString()); + templateData.put("alarmOriginatorEntityType", alarmOriginator.getEntityType().getNormalName()); + templateData.put("alarmOriginatorName", alarmOriginatorName); + templateData.put("alarmOriginatorLabel", alarmOriginatorLabel); + templateData.put("alarmOriginatorId", alarmOriginator.getId().toString()); + return templateData; } @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java index 81b5e0ccfb..fe87e0f966 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java @@ -62,6 +62,7 @@ public class NotificationRule extends BaseData implements Ha @Valid private NotificationRuleRecipientsConfig recipientsConfig; + @Valid private NotificationRuleConfig additionalConfig; private NotificationRuleId externalId; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleConfig.java index 9103086b7c..013c0ae662 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleConfig.java @@ -16,12 +16,14 @@ package org.thingsboard.server.common.data.notification.rule; import lombok.Data; +import org.thingsboard.server.common.data.validation.NoXss; import java.io.Serializable; @Data public class NotificationRuleConfig implements Serializable { + @NoXss private String description; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmCommentTrigger.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmCommentTrigger.java index a9cee05707..e6298691ea 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmCommentTrigger.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmCommentTrigger.java @@ -25,10 +25,15 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; +import java.io.Serial; + @Data @Builder public class AlarmCommentTrigger implements NotificationRuleTrigger { + @Serial + private static final long serialVersionUID = -8614770559491757202L; + private final TenantId tenantId; private final AlarmComment comment; private final Alarm alarm; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmTrigger.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmTrigger.java index a64bf13266..275c3b1e18 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmTrigger.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/AlarmTrigger.java @@ -22,10 +22,15 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; +import java.io.Serial; + @Data @Builder public class AlarmTrigger implements NotificationRuleTrigger { + @Serial + private static final long serialVersionUID = -466810297904938644L; + private final TenantId tenantId; private final AlarmApiCallResult alarmUpdate; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmAssignmentNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmAssignmentNotificationRuleTriggerConfig.java index a63a2d0cdf..cf701b5221 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmAssignmentNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmAssignmentNotificationRuleTriggerConfig.java @@ -23,6 +23,7 @@ import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import java.io.Serial; import java.util.Set; @Data @@ -31,6 +32,9 @@ import java.util.Set; @Builder public class AlarmAssignmentNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @Serial + private static final long serialVersionUID = -5313556049809972096L; + private Set alarmTypes; private Set alarmSeverities; private Set alarmStatuses; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmCommentNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmCommentNotificationRuleTriggerConfig.java index e2da4f5291..62762cce6e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmCommentNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmCommentNotificationRuleTriggerConfig.java @@ -22,6 +22,7 @@ import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import java.io.Serial; import java.util.Set; @Data @@ -30,6 +31,9 @@ import java.util.Set; @Builder public class AlarmCommentNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @Serial + private static final long serialVersionUID = -9164282098882339645L; + private Set alarmTypes; private Set alarmSeverities; private Set alarmStatuses; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmNotificationRuleTriggerConfig.java index 53f160b8ad..5006e63abd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmNotificationRuleTriggerConfig.java @@ -23,6 +23,7 @@ import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import java.io.Serial; import java.io.Serializable; import java.util.Set; @@ -32,6 +33,9 @@ import java.util.Set; @Builder public class AlarmNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @Serial + private static final long serialVersionUID = -7382883720381542344L; + private Set alarmTypes; private Set alarmSeverities; @NotEmpty @@ -46,6 +50,8 @@ public class AlarmNotificationRuleTriggerConfig implements NotificationRuleTrigg @Data public static class ClearRule implements Serializable { + @Serial + private static final long serialVersionUID = 7922533150038105124L; private Set alarmStatuses; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java index 79c7dafb1d..0d629fe970 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java @@ -18,7 +18,7 @@ package org.thingsboard.server.common.data.notification.targets.platform; import lombok.Data; @Data -public class AllUsersFilter implements UsersFilter { +public class AllUsersFilter implements SystemLevelUsersFilter { @Override public UsersFilterType getType() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java index c178ee1159..4d35488ec1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java @@ -18,7 +18,7 @@ package org.thingsboard.server.common.data.notification.targets.platform; import lombok.Data; @Data -public class SystemAdministratorsFilter implements UsersFilter { +public class SystemAdministratorsFilter implements SystemLevelUsersFilter { @Override public UsersFilterType getType() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemLevelUsersFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemLevelUsersFilter.java new file mode 100644 index 0000000000..20233f7e49 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemLevelUsersFilter.java @@ -0,0 +1,19 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.notification.targets.platform; + +public interface SystemLevelUsersFilter extends UsersFilter { +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java index 1f78790788..2f72e66076 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java @@ -21,7 +21,7 @@ import java.util.Set; import java.util.UUID; @Data -public class TenantAdministratorsFilter implements UsersFilter { +public class TenantAdministratorsFilter implements SystemLevelUsersFilter { private Set tenantsIds; private Set tenantProfilesIds; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentLifecycleEvent.java b/common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentLifecycleEvent.java index 5d13db2348..31cab71e0e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentLifecycleEvent.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentLifecycleEvent.java @@ -32,7 +32,9 @@ public enum ComponentLifecycleEvent implements Serializable { STOPPED(5), DELETED(6), FAILED(7), - DEACTIVATED(8); + DEACTIVATED(8), + RELATION_UPDATED(9), + RELATION_DELETED(10); @Getter private final int protoNumber; // corresponds to ComponentLifecycleEvent proto diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelationPathQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelationPathQuery.java new file mode 100644 index 0000000000..a81e3e0c86 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelationPathQuery.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.relation; + +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.List; + +public record EntityRelationPathQuery(EntityId rootEntityId, List levels) { + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationPathLevel.java b/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationPathLevel.java new file mode 100644 index 0000000000..c04ca09e79 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationPathLevel.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.relation; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.thingsboard.server.common.data.StringUtils; + +public record RelationPathLevel(@NotNull EntitySearchDirection direction, @NotBlank String relationType) { + + public void validate() { + if (direction == null) { + throw new IllegalArgumentException("Direction must be specified!"); + } + if (StringUtils.isBlank(relationType)) { + throw new IllegalArgumentException("Relation type must be specified!"); + } + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java index 1b832c847d..deaaf263ca 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java @@ -20,8 +20,10 @@ public enum Authority { SYS_ADMIN(0), TENANT_ADMIN(1), CUSTOMER_USER(2), + REFRESH_TOKEN(10), - PRE_VERIFICATION_TOKEN(11); + PRE_VERIFICATION_TOKEN(11), + MFA_CONFIGURATION_TOKEN(12); private int code; 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 ad73510d9e..6d1dbdcb4a 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 @@ -24,11 +24,15 @@ import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.id.DeviceCredentialsId; import org.thingsboard.server.common.data.id.DeviceId; +import java.io.Serial; + @Schema @EqualsAndHashCode(callSuper = true) public class DeviceCredentials extends BaseData implements DeviceCredentialsFilter, HasVersion { + @Serial private static final long serialVersionUID = -7869261127032877765L; + private DeviceId deviceId; private DeviceCredentialsType credentialsType; private String credentialsId; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java index 49b7e707cf..c1c632f016 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java @@ -21,6 +21,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import lombok.Data; +import org.thingsboard.server.common.data.notification.targets.platform.SystemLevelUsersFilter; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; @@ -46,6 +47,8 @@ public class PlatformTwoFaSettings { @Min(value = 60) private Integer totalAllowedTimeForVerification; + private boolean enforceTwoFa; + private SystemLevelUsersFilter enforcedUsersFilter; public Optional getProviderConfig(TwoFaProviderType providerType) { return Optional.ofNullable(providers) 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 4c8b9e06bd..0e246f9268 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 @@ -172,10 +172,12 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private long maxCalculatedFieldsPerEntity = 5; @Schema(example = "10") private long maxArgumentsPerCF = 10; - @Schema(example = "3600") - private int minAllowedScheduledUpdateIntervalInSecForCF = 3600; + @Schema(example = "60") + private int minAllowedScheduledUpdateIntervalInSecForCF = 60; @Schema(example = "10") private int maxRelationLevelPerCfArgument = 10; + @Schema(example = "100") + private int maxRelatedEntitiesToReturnPerCfArgument = 100; @Builder.Default @Min(value = 1, message = "must be at least 1") @Schema(example = "1000") @@ -184,6 +186,8 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private long maxStateSizeInKBytes = 32; @Schema(example = "2") private long maxSingleValueArgumentSizeInKBytes = 2; + @Schema(example = "60") + private long minAllowedDeduplicationIntervalInSecForCF = 60; @Override public long getProfileThreshold(ApiUsageRecordKey key) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java b/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java index 71c5256203..0d69db556d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/util/CollectionsUtil.java @@ -18,9 +18,11 @@ package org.thingsboard.server.common.data.util; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.BiPredicate; import java.util.stream.Collectors; public class CollectionsUtil { @@ -95,4 +97,44 @@ public class CollectionsUtil { return false; } + public static boolean elementsEqual(Iterable iterable1, Iterable iterable2, BiPredicate equalityCheck) { + if (iterable1 instanceof Collection collection1 && iterable2 instanceof Collection collection2) { + if (collection1.size() != collection2.size()) { + return false; + } + } + + Iterator iterator1 = iterable1.iterator(); + Iterator iterator2 = iterable2.iterator(); + while (true) { + if (iterator1.hasNext()) { + if (!iterator2.hasNext()) { + return false; + } + + T o1 = iterator1.next(); + T o2 = iterator2.next(); + if (equalityCheck.test(o1, o2)) { + continue; + } else { + return false; + } + } + return !iterator2.hasNext(); + } + } + + public static Set addToSet(Set existing, T value) { + if (existing == null || existing.isEmpty()) { + return Set.of(value); + } + if (existing.contains(value)) { + return existing; + } + Set newSet = new HashSet<>(existing.size() + 1); + newSet.addAll(existing); + newSet.add(value); + return (Set) Set.of(newSet.toArray()); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java b/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java index 85071b3cc9..82de579d3d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.util; +import com.google.gson.JsonParser; import org.apache.commons.lang3.math.NumberUtils; import org.apache.commons.lang3.tuple.Pair; import org.thingsboard.server.common.data.kv.DataType; @@ -40,6 +41,11 @@ public class TypeCastUtil { } catch (RuntimeException ignored) {} } else if (value.equalsIgnoreCase("true") || value.equalsIgnoreCase("false")) { return Pair.of(DataType.BOOLEAN, Boolean.parseBoolean(value)); + } else if (looksLikeJson(value)) { + try { + return Pair.of(DataType.JSON, JsonParser.parseString(value)); + } catch (Exception ignored) { + } } return Pair.of(DataType.STRING, value); } @@ -70,4 +76,10 @@ public class TypeCastUtil { return valueAsString.contains(".") && !valueAsString.contains("E") && !valueAsString.contains("e"); } + private static boolean looksLikeJson(String value) { + String trimmed = value.trim(); + return (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")); + } + } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java index fd59317649..ec99b29fe8 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java @@ -29,10 +29,21 @@ public class ArgumentTest { } @Test - void validateShouldReturnTrueIfDynamicSourceConfigurationIsNotNull() { + void validateWhenRelationQuerySourceConfigurationIsNotNull() { var argument = new Argument(); - argument.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration()); + argument.setRefDynamicSourceConfiguration(new RelationPathQueryDynamicSourceConfiguration()); assertThat(argument.hasDynamicSource()).isTrue(); + assertThat(argument.hasRelationQuerySource()).isTrue(); + assertThat(argument.hasOwnerSource()).isFalse(); + } + + @Test + void validateWhenCurrentOwnerSourceConfigurationIsNotNull() { + var argument = new Argument(); + argument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); + assertThat(argument.hasDynamicSource()).isTrue(); + assertThat(argument.hasOwnerSource()).isTrue(); + assertThat(argument.hasRelationQuerySource()).isFalse(); } } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java new file mode 100644 index 0000000000..9c77f1bd21 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java @@ -0,0 +1,154 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.configuration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; + +@ExtendWith(MockitoExtension.class) +public class PropagationCalculatedFieldConfigurationTest { + + @Test + void typeShouldBePropagation() { + var cfg = new PropagationCalculatedFieldConfiguration(); + assertThat(cfg.getType()).isEqualTo(CalculatedFieldType.PROPAGATION); + } + + @Test + void validateShouldThrowWhenConfigurationDisallowArgumentsWithReferencedEntity() { + var cfg = new PropagationCalculatedFieldConfiguration(); + Argument argumentWithRefEntityIdSet = new Argument(); + argumentWithRefEntityIdSet.setRefEntityId(new DeviceId(UUID.fromString("bda14084-f40e-4acc-9b85-9d1dd209bb64"))); + cfg.setArguments(Map.of("argumentWithRefEntityIdSet", argumentWithRefEntityIdSet)); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Arguments in 'Arguments only' propagation mode support only the 'Current entity' source entity type!"); + } + + @Test + void validateShouldThrowWhenConfigurationDisallowArgumentsWithDynamicReferenceConfiguration() { + var cfg = new PropagationCalculatedFieldConfiguration(); + Argument argumentWithDynamicRefEntitySource = new Argument(); + argumentWithDynamicRefEntitySource.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); + cfg.setArguments(Map.of("argumentWithDynamicRefEntitySource", argumentWithDynamicRefEntitySource)); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Arguments in 'Arguments only' propagation mode support only the 'Current entity' source entity type!"); + } + + @Test + void validateShouldThrowWhenConfigurationHasNoArgumentsWithCurrentEntitySource() { + var cfg = new PropagationCalculatedFieldConfiguration(); + Argument argumentWithRefEntityIdSet = new Argument(); + argumentWithRefEntityIdSet.setRefEntityId(new DeviceId(UUID.fromString("3703e895-3f9b-4b75-a715-b68f1ad51944"))); + cfg.setArguments(Map.of("argumentWithRefEntityIdSet", argumentWithRefEntityIdSet)); + cfg.setApplyExpressionToResolvedArguments(true); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("At least one argument must be configured with the 'Current entity' " + + "source entity type for 'Expression result' propagation mode!"); + } + + @Test + void validateShouldThrowWhenUsedReservedPropagationArgumentName() { + var cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setArguments(Map.of(PROPAGATION_CONFIG_ARGUMENT, new Argument())); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Argument name '" + PROPAGATION_CONFIG_ARGUMENT + "' is reserved and cannot be used."); + } + + @Test + void validateShouldThrowWhenUsedReservedCtxArgumentName() { + var cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setArguments(Map.of("ctx", new Argument())); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Argument name 'ctx' is reserved and cannot be used."); + } + + @Test + void validateShouldThrowWhenReferencedEntityKeyIsNotSet() { + var cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.TO, EntityRelation.CONTAINS_TYPE)); + Argument argument = new Argument(); + cfg.setArguments(Map.of("someArgumentName", argument)); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Argument: 'someArgumentName' doesn't have reference entity key configured!"); + } + + @Test + void validateShouldThrowWhenReferencedEntityKeyTypeIsTsRolling() { + var cfg = new PropagationCalculatedFieldConfiguration(); + ReferencedEntityKey referencedEntityKey = new ReferencedEntityKey("someKey", ArgumentType.TS_ROLLING, null); + Argument argument = new Argument(); + argument.setRefEntityKey(referencedEntityKey); + cfg.setArguments(Map.of("someArgumentName", argument)); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Argument type: 'Time series rolling' detected for argument: 'someArgumentName'. " + + "Only 'Attribute' or 'Latest telemetry' arguments are allowed for 'Arguments only' propagation mode!"); + } + + @Test + void validateShouldThrowWhenExpressionIsNotSet() { + var cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setArguments(Map.of("someArgumentName", new Argument())); + cfg.setApplyExpressionToResolvedArguments(true); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Expression must be specified for 'Expression result' propagation mode!"); + } + + @Test + void validateToPropagationArgumentMethodCallReturnCorrectArgument() { + var cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.TO, EntityRelation.CONTAINS_TYPE)); + + Argument propagationArgument = cfg.toPropagationArgument(); + assertThat(propagationArgument).isNotNull(); + assertThat(propagationArgument.getRefEntityId()).isNull(); + assertThat(propagationArgument.getRefEntityKey()).isNull(); + assertThat(propagationArgument.getDefaultValue()).isNull(); + assertThat(propagationArgument.getTimeWindow()).isNull(); + assertThat(propagationArgument.getLimit()).isNull(); + + assertThat(propagationArgument.getRefDynamicSourceConfiguration()) + .isNotNull() + .isInstanceOf(RelationPathQueryDynamicSourceConfiguration.class); + var refDynamicSourceConfiguration = (RelationPathQueryDynamicSourceConfiguration) propagationArgument.getRefDynamicSourceConfiguration(); + assertThat(refDynamicSourceConfiguration.getLevels()).isNotEmpty().hasSize(1); + + var relationPathLevel = refDynamicSourceConfiguration.getLevels().get(0); + assertThat(relationPathLevel.direction()).isEqualTo(EntitySearchDirection.TO); + assertThat(relationPathLevel.relationType()).isEqualTo(EntityRelation.CONTAINS_TYPE); + } + +} diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfigurationTest.java new file mode 100644 index 0000000000..1a6ea5de3b --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfigurationTest.java @@ -0,0 +1,126 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.cf.configuration; + +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.NullAndEmptySource; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; + +import java.util.ArrayList; +import java.util.List; + +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.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class RelationPathQueryDynamicSourceConfigurationTest { + + @Test + void typeShouldBeRelationQuery() { + var cfg = new RelationPathQueryDynamicSourceConfiguration(); + assertThat(cfg.getType()).isEqualTo(CFArgumentDynamicSourceType.RELATION_PATH_QUERY); + } + + @ParameterizedTest + @NullAndEmptySource + void validateShouldThrowWhenLevelsIsNull(List levels) { + var cfg = new RelationPathQueryDynamicSourceConfiguration(); + cfg.setLevels(levels); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("At least one relation level must be specified!"); + } + + @Test + void validateShouldCallValidateForPathLevels() { + List levels = new ArrayList<>(); + + RelationPathLevel lvl1 = mock(RelationPathLevel.class); + RelationPathLevel lvl2 = mock(RelationPathLevel.class); + levels.add(lvl1); + levels.add(lvl2); + + var cfg = new RelationPathQueryDynamicSourceConfiguration(); + cfg.setLevels(levels); + + assertThatCode(cfg::validate).doesNotThrowAnyException(); + + verify(lvl1).validate(); + verify(lvl2).validate(); + } + + @Test + void resolveEntityIds_whenDirectionFROM_thenReturnsToIds() { + List levels = new ArrayList<>(); + + RelationPathLevel lvl1 = mock(RelationPathLevel.class); + RelationPathLevel lvl2 = mock(RelationPathLevel.class); + levels.add(lvl1); + levels.add(lvl2); + + when(lvl2.direction()).thenReturn(EntitySearchDirection.FROM); + + EntityRelation rel1 = mock(EntityRelation.class); + EntityRelation rel2 = mock(EntityRelation.class); + + when(rel1.getTo()).thenReturn(mock(EntityId.class)); + when(rel2.getTo()).thenReturn(mock(EntityId.class)); + + var cfg = new RelationPathQueryDynamicSourceConfiguration(); + cfg.setLevels(levels); + + var out = cfg.resolveEntityIds(List.of(rel1, rel2)); + + assertThat(out).containsExactly(rel1.getTo(), rel2.getTo()); + } + + @Test + void resolveEntityIds_whenDirectionTO_thenReturnsFromIds() { + List levels = new ArrayList<>(); + + RelationPathLevel lvl1 = mock(RelationPathLevel.class); + RelationPathLevel lvl2 = mock(RelationPathLevel.class); + levels.add(lvl1); + levels.add(lvl2); + + when(lvl2.direction()).thenReturn(EntitySearchDirection.TO); + + EntityRelation rel1 = mock(EntityRelation.class); + EntityRelation rel2 = mock(EntityRelation.class); + + when(rel1.getFrom()).thenReturn(mock(EntityId.class)); + when(rel2.getFrom()).thenReturn(mock(EntityId.class)); + + var cfg = new RelationPathQueryDynamicSourceConfiguration(); + cfg.setLevels(levels); + + var out = cfg.resolveEntityIds(List.of(rel1, rel2)); + + assertThat(out).containsExactly(rel1.getFrom(), rel2.getFrom()); + } + +} diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java deleted file mode 100644 index 86fa52ba66..0000000000 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Copyright © 2016-2025 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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.cf.configuration; - -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.NullAndEmptySource; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.relation.EntityRelation; -import org.thingsboard.server.common.data.relation.EntitySearchDirection; -import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; -import org.thingsboard.server.common.data.relation.RelationsSearchParameters; - -import java.util.List; - -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.Mockito.mock; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -public class RelationQueryDynamicSourceConfigurationTest { - - @Mock - EntityId rootEntityId; - - @Mock - EntityRelation rel1; - @Mock - EntityRelation rel2; - - @Test - void typeShouldBeRelationQuery() { - var cfg = new RelationQueryDynamicSourceConfiguration(); - assertThat(cfg.getType()).isEqualTo(CFArgumentDynamicSourceType.RELATION_QUERY); - } - - @Test - void validateShouldThrowWhenMaxLevelLessThanOne() { - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(0); - cfg.setDirection(EntitySearchDirection.FROM); - cfg.setRelationType(EntityRelation.CONTAINS_TYPE); - - assertThatThrownBy(cfg::validate) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Relation query dynamic source configuration max relation level can't be less than 1!"); - } - - @Test - void validateShouldThrowWhenMaxLevelGreaterThanMaxAllowedLevelFromTenantProfile() { - int maxAllowedRelationLevel = 2; - int argumentMaxRelationLevel = 3; - - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(argumentMaxRelationLevel); - cfg.setDirection(EntitySearchDirection.FROM); - cfg.setRelationType(EntityRelation.CONTAINS_TYPE); - - String testRelationArgument = "testRelationArgument"; - assertThatThrownBy(() -> cfg.validateMaxRelationLevel(testRelationArgument, maxAllowedRelationLevel)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Max relation level is greater than configured " + - "maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + testRelationArgument); - } - - @Test - void validateShouldPassValidationWhenMaxLevelLessThanMaxAllowedLevelFromTenantProfile() { - int maxAllowedRelationLevel = 5; - int argumentMaxRelationLevel = 2; - - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(argumentMaxRelationLevel); - cfg.setDirection(EntitySearchDirection.FROM); - cfg.setRelationType(EntityRelation.CONTAINS_TYPE); - - String testRelationArgument = "testRelationArgument"; - assertThatCode(() -> cfg.validateMaxRelationLevel(testRelationArgument, maxAllowedRelationLevel)).doesNotThrowAnyException(); - } - - @Test - void validateShouldThrowWhenDirectionIsNull() { - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(1); - cfg.setDirection(null); - cfg.setRelationType(EntityRelation.CONTAINS_TYPE); - - assertThatThrownBy(cfg::validate) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Relation query dynamic source configuration direction must be specified!"); - } - - @ParameterizedTest - @ValueSource(strings = {" "}) - @NullAndEmptySource - void validateShouldThrowWhenRelationTypeIsNull(String relationType) { - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(1); - cfg.setDirection(EntitySearchDirection.TO); - cfg.setRelationType(relationType); - - assertThatThrownBy(cfg::validate) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Relation query dynamic source configuration relation type must be specified!"); - } - - @ParameterizedTest - @NullAndEmptySource - void isSimpleRelationTrueWhenLevelIsOneAndEntityTypesEmptyOrNull(List entityTypes) { - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(1); - assertThat(cfg.isSimpleRelation()).isTrue(); - } - - @Test - void isSimpleRelationFalseWhenMaxLevelNotOne() { - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(2); - assertThat(cfg.isSimpleRelation()).isFalse(); - } - - @ParameterizedTest - @NullAndEmptySource - void toEntityRelationsQueryShouldThrowForSimpleRelation(List entityTypes) { - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(1); - cfg.setFetchLastLevelOnly(false); - cfg.setDirection(EntitySearchDirection.FROM); - cfg.setRelationType(EntityRelation.CONTAINS_TYPE); - - assertThatThrownBy(() -> cfg.toEntityRelationsQuery(rootEntityId)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Entity relations query can't be created for a simple relation!"); - } - - @Test - void toEntityRelationsQueryShouldBuildQueryForNonSimpleRelation() { - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(2); - cfg.setFetchLastLevelOnly(true); - cfg.setDirection(EntitySearchDirection.TO); - cfg.setRelationType(EntityRelation.MANAGES_TYPE); - - var query = cfg.toEntityRelationsQuery(rootEntityId); - - assertThat(query).isNotNull(); - RelationsSearchParameters params = query.getParameters(); - assertThat(params).isNotNull(); - assertThat(params.getRootId()).isEqualTo(rootEntityId.getId()); - assertThat(params.getDirection()).isEqualTo(EntitySearchDirection.TO); - assertThat(params.getMaxLevel()).isEqualTo(2); - assertThat(params.isFetchLastLevelOnly()).isTrue(); - - assertThat(query.getFilters()).hasSize(1); - assertThat(query.getFilters().get(0)).isInstanceOf(RelationEntityTypeFilter.class); - RelationEntityTypeFilter filter = query.getFilters().get(0); - assertThat(filter.getRelationType()).isEqualTo(EntityRelation.MANAGES_TYPE); - } - - @Test - void resolveEntityIdsFromDirectionFROMReturnsToIds() { - when(rel1.getTo()).thenReturn(mock(EntityId.class)); - when(rel2.getTo()).thenReturn(mock(EntityId.class)); - - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setDirection(EntitySearchDirection.FROM); - - var out = cfg.resolveEntityIds(List.of(rel1, rel2)); - - assertThat(out).containsExactly(rel1.getTo(), rel2.getTo()); - } - - @Test - void resolveEntityIdsFromDirectionTOReturnsFromIds() { - when(rel1.getFrom()).thenReturn(mock(EntityId.class)); - when(rel2.getFrom()).thenReturn(mock(EntityId.class)); - - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setDirection(EntitySearchDirection.TO); - - var out = cfg.resolveEntityIds(List.of(rel1, rel2)); - - assertThat(out).containsExactly(rel1.getFrom(), rel2.getFrom()); - } - - @Test - void validateShouldPassForValidConfig() { - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(2); - cfg.setFetchLastLevelOnly(false); - cfg.setDirection(EntitySearchDirection.FROM); - cfg.setRelationType(EntityRelation.CONTAINS_TYPE); - - assertThatCode(cfg::validate).doesNotThrowAnyException(); - } - -} diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java index 3c0956bd08..15a191be97 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java @@ -29,7 +29,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; public class ScheduledUpdateSupportedCalculatedFieldConfigurationTest { @Test - void validateShouldThrowWhenScheduledUpdateIntervalIsSetButTimeUnitIsNotSupported() { + void validateDoesNotThrowAnyExceptionWhenScheduledUpdateIntervalIsGreaterThanMinAllowedIntervalInTenantProfile() { int scheduledUpdateInterval = 60; int minAllowedInterval = scheduledUpdateInterval - 1; diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java index c5d627c3e6..a8ee18c7d7 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java @@ -16,47 +16,16 @@ package org.thingsboard.server.common.data.cf.configuration.geofencing; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.NullAndEmptySource; -import org.junit.jupiter.params.provider.ValueSource; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; 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.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY; import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY; public class EntityCoordinatesTest { - @ParameterizedTest - @ValueSource(strings = " ") - @NullAndEmptySource - void validateShouldThrowWhenLatitudeCoordinateIsNullEmptyOrBlank(String latitudeKey) { - var entityCoordinates = new EntityCoordinates(latitudeKey, "longitude"); - assertThatThrownBy(entityCoordinates::validate) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Entity coordinates latitude key name must be specified!"); - } - - @ParameterizedTest - @ValueSource(strings = " ") - @NullAndEmptySource - void validateShouldThrowWhenLongitudeCoordinateIsNullEmptyOrBlank(String longitudeKey) { - var entityCoordinates = new EntityCoordinates("latitude", longitudeKey); - assertThatThrownBy(entityCoordinates::validate) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Entity coordinates longitude key name must be specified!"); - } - - @Test - void validateShouldPassOnMinimalValidConfig() { - var entityCoordinates = new EntityCoordinates("latitude", "longitude"); - assertThatCode(entityCoordinates::validate).doesNotThrowAnyException(); - } - @Test void validateToArgumentsMethodCallWithoutRefEntityId() { var entityCoordinates = new EntityCoordinates("xPos", "yPos"); diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java index 91a47aac57..2e9d5e4a88 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java @@ -24,16 +24,12 @@ import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; -import java.util.List; import java.util.Map; 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.Mockito.mock; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY; import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY; @@ -47,28 +43,7 @@ public class GeofencingCalculatedFieldConfigurationTest { } @Test - void validateShouldThrowWhenEntityCoordinatesNull() { - var cfg = new GeofencingCalculatedFieldConfiguration(); - cfg.setEntityCoordinates(null); - - assertThatThrownBy(cfg::validate) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Geofencing calculated field entity coordinates must be specified!"); - } - - @Test - void validateShouldThrowWhenZoneGroupsNull() { - var cfg = new GeofencingCalculatedFieldConfiguration(); - cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY)); - cfg.setZoneGroups(null); - - assertThatThrownBy(cfg::validate) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Geofencing calculated field must contain at least one geofencing zone group defined!"); - } - - @Test - void validateShouldCallValidateOnEntityCoordinatesAndZoneGroups() { + void validateShouldCallValidateOnZoneGroups() { var cfg = new GeofencingCalculatedFieldConfiguration(); EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class); cfg.setEntityCoordinates(entityCoordinatesMock); @@ -76,13 +51,11 @@ public class GeofencingCalculatedFieldConfigurationTest { cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfiguration)); cfg.validate(); - - verify(entityCoordinatesMock).validate(); verify(zoneGroupConfiguration).validate("someGroupName"); } @Test - void validateShouldCallValidateOnEntityCoordinatesAndZoneGroupsWithoutAnyExceptions() { + void validateShouldCallValidateOnZoneGroupsWithoutAnyExceptions() { var cfg = new GeofencingCalculatedFieldConfiguration(); EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class); cfg.setEntityCoordinates(entityCoordinatesMock); @@ -96,38 +69,10 @@ public class GeofencingCalculatedFieldConfigurationTest { assertThatCode(cfg::validate).doesNotThrowAnyException(); - verify(entityCoordinatesMock).validate(); verify(zoneGroupConfigurationA).validate(zoneGroupAName); verify(zoneGroupConfigurationB).validate(zoneGroupBName); } - @Test - void scheduledUpdateDisabledWhenIntervalIsZero() { - var cfg = new GeofencingCalculatedFieldConfiguration(); - cfg.setScheduledUpdateInterval(0); - assertThat(cfg.isScheduledUpdateEnabled()).isFalse(); - } - - @Test - void scheduledUpdateDisabledWhenIntervalIsGreaterThanZeroButNoZonesWithDynamicArguments() { - var cfg = new GeofencingCalculatedFieldConfiguration(); - var zoneGroupConfigurationMock = mock(ZoneGroupConfiguration.class); - when(zoneGroupConfigurationMock.hasDynamicSource()).thenReturn(false); - cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfigurationMock)); - cfg.setScheduledUpdateInterval(60); - assertThat(cfg.isScheduledUpdateEnabled()).isFalse(); - } - - @Test - void scheduledUpdateEnabledWhenIntervalIsGreaterThanZeroAndDynamicArgumentsPresent() { - var cfg = new GeofencingCalculatedFieldConfiguration(); - var zoneGroupConfigurationMock = mock(ZoneGroupConfiguration.class); - when(zoneGroupConfigurationMock.hasDynamicSource()).thenReturn(true); - cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfigurationMock)); - cfg.setScheduledUpdateInterval(60); - assertThat(cfg.isScheduledUpdateEnabled()).isTrue(); - } - @Test void testGetArgumentsOverride() { var cfg = new GeofencingCalculatedFieldConfiguration(); diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java index 4eb822d93c..e354a05af9 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java @@ -22,8 +22,9 @@ import org.junit.jupiter.params.provider.ValueSource; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.CurrentOwnerDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; -import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; @@ -44,24 +45,6 @@ public class ZoneGroupConfigurationTest { .hasMessage("Name '" + name + "' is reserved and cannot be used for zone group!"); } - @ParameterizedTest - @ValueSource(strings = " ") - @NullAndEmptySource - void validateShouldThrowWhenPerimeterKeyNameIsNullEmptyOrBlank(String perimeterKeyName) { - var zoneGroupConfiguration = new ZoneGroupConfiguration(perimeterKeyName, REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Perimeter key name must be specified for 'allowedZonesGroup' zone group!"); - } - - @Test - void validateShouldThrowWhenReportStrategyIsNull() { - var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", null, false); - assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Report strategy must be specified for 'allowedZonesGroup' zone group!"); - } - @ParameterizedTest @ValueSource(strings = " ") @NullAndEmptySource @@ -98,19 +81,25 @@ public class ZoneGroupConfigurationTest { } @Test - void whenHasDynamicSourceCalled_shouldReturnTrueIfDynamicSourceConfigurationIsNotNull() { + void whenHasRelationQuerySourceCalled_shouldReturnTrueIfRelationQuerySourceConfigurationIsNotNull() { var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - zoneGroupConfiguration.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration()); - assertThat(zoneGroupConfiguration.hasDynamicSource()).isTrue(); + zoneGroupConfiguration.setRefDynamicSourceConfiguration(new RelationPathQueryDynamicSourceConfiguration()); + assertThat(zoneGroupConfiguration.hasRelationQuerySource()).isTrue(); } @Test - void whenHasDynamicSourceCalled_shouldReturnTrueIfDynamicSourceConfigurationIsNull() { + void whenHasRelationQuerySourceCalled_shouldReturnFalseIfRelationQuerySourceConfigurationIsNull() { var zoneGroupConfiguration = mock(ZoneGroupConfiguration.class); assertThat(zoneGroupConfiguration.getRefDynamicSourceConfiguration()).isNull(); - assertThat(zoneGroupConfiguration.hasDynamicSource()).isFalse(); + assertThat(zoneGroupConfiguration.hasRelationQuerySource()).isFalse(); } + @Test + void whenHasRelationQuerySourceCalled_shouldReturnFalseIfCurrentOwnerSourceConfigured() { + var zoneGroupConfiguration = mock(ZoneGroupConfiguration.class); + zoneGroupConfiguration.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); + assertThat(zoneGroupConfiguration.hasRelationQuerySource()).isFalse(); + } @Test void validateToArgumentsMethodCallWithoutRefEntityId() { diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/msg/TbMsgTypeTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/msg/TbMsgTypeTest.java index 563c6015ef..54238c8751 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/msg/TbMsgTypeTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/msg/TbMsgTypeTest.java @@ -20,7 +20,6 @@ import org.junit.jupiter.api.Test; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; -import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM; import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_DELETE; import static org.thingsboard.server.common.data.msg.TbMsgType.DEDUPLICATION_TIMEOUT_SELF_MSG; import static org.thingsboard.server.common.data.msg.TbMsgType.DELAY_TIMEOUT_SELF_MSG; @@ -39,7 +38,6 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.SEND_EMAIL; class TbMsgTypeTest { private static final List typesWithNullRuleNodeConnection = List.of( - ALARM, ALARM_DELETE, ENTITY_ASSIGNED_TO_EDGE, ENTITY_UNASSIGNED_FROM_EDGE, diff --git a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java index 0eff7ad053..dbaef23431 100644 --- a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java +++ b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java @@ -136,7 +136,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { .setConnectRequestMsg(ConnectRequestMsg.newBuilder() .setEdgeRoutingKey(edgeKey) .setEdgeSecret(edgeSecret) - .setEdgeVersion(EdgeVersion.V_4_2_0) + .setEdgeVersion(EdgeVersion.V_4_3_0) .setMaxInboundMessageSize(maxInboundMessageSize) .build()) .build()); diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index 7eddb326ef..a7a08e6948 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -44,6 +44,7 @@ enum EdgeVersion { V_4_0_0 = 10; V_4_1_0 = 11; V_4_2_0 = 12; + V_4_3_0 = 13; V_LATEST = 999; } @@ -133,6 +134,12 @@ message CalculatedFieldUpdateMsg{ string entity = 4; } +message AiModelUpdateMsg{ + UpdateMsgType msgType = 1; + int64 idMSB = 2; + int64 idLSB = 3; + string entity = 4; +} message EntityDataProto { int64 entityIdMSB = 1; @@ -441,8 +448,9 @@ message UplinkMsg { repeated RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = 24; repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 25; repeated CalculatedFieldRequestMsg calculatedFieldRequestMsg = 26; - repeated UserUpdateMsg userUpdateMsg = 27; - repeated UserCredentialsUpdateMsg userCredentialsUpdateMsg = 28; + repeated AiModelUpdateMsg aiModelUpdateMsg = 27; + repeated UserUpdateMsg userUpdateMsg = 28; + repeated UserCredentialsUpdateMsg userCredentialsUpdateMsg = 29; } message UplinkResponseMsg { @@ -493,4 +501,5 @@ message DownlinkMsg { repeated NotificationTemplateUpdateMsg notificationTemplateUpdateMsg = 33; repeated OAuth2DomainUpdateMsg oAuth2DomainUpdateMsg = 34; repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 35; + repeated AiModelUpdateMsg aiModelUpdateMsg = 36; } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java index 21b9ff0c4f..b75ce6a315 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java @@ -20,6 +20,7 @@ import lombok.Setter; import lombok.ToString; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.edqs.fields.EntityFields; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.permission.QueryContext; @@ -139,11 +140,32 @@ public abstract class BaseEntityData implements EntityDa case "name" -> getEntityName(); case "ownerName" -> getOwnerName(); case "ownerType" -> getOwnerType(); + case "displayName" -> getDisplayName(); case "entityType" -> Optional.ofNullable(getEntityType()).map(EntityType::name).orElse(""); default -> fields.getAsString(name); }; } + public String getDisplayName(){ + return switch (getEntityType()) { + case DEVICE, ASSET -> StringUtils.isNotBlank(fields.getLabel()) ? fields.getLabel() : fields.getName(); + case USER -> { + boolean firstNameSet = StringUtils.isNotBlank(fields.getFirstName()); + boolean lastNameSet = StringUtils.isNotBlank(fields.getLastName()); + if(firstNameSet && lastNameSet) { + yield fields.getFirstName() + " " + fields.getLastName(); + } else if(firstNameSet) { + yield fields.getFirstName(); + } else if (lastNameSet) { + yield fields.getLastName(); + } else { + yield fields.getEmail(); + } + } + default -> fields.getName(); + }; + } + public String getEntityName() { return getFields().getName(); } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/DeviceData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/DeviceData.java index 3a3e5c5792..f47bfcb28a 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/DeviceData.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/DeviceData.java @@ -18,6 +18,7 @@ package org.thingsboard.server.edqs.data; import lombok.ToString; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.edqs.fields.DeviceFields; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.edqs.DataPoint; diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/CalculatedFieldStatePartitionRestoreMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/CalculatedFieldStatePartitionRestoreMsg.java new file mode 100644 index 0000000000..b16e2adb85 --- /dev/null +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/CalculatedFieldStatePartitionRestoreMsg.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.msg; + +import lombok.Data; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; + +@Data +public class CalculatedFieldStatePartitionRestoreMsg implements ToCalculatedFieldSystemMsg { + + private final TopicPartitionInfo partition; + + @Override + public TenantId getTenantId() { + return TenantId.SYS_TENANT_ID; + } + + @Override + public MsgType getMsgType() { + return MsgType.CF_STATE_PARTITION_RESTORE_MSG; + } + +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java b/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java index 48b07af29b..85ce75c829 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java @@ -138,9 +138,12 @@ public enum MsgType { CF_CACHE_INIT_MSG, // Sent to init caches for CF actor; CF_STATE_RESTORE_MSG, // Sent to restore particular calculated field entity state; + CF_STATE_PARTITION_RESTORE_MSG, CF_PARTITIONS_CHANGE_MSG, // Sent when cluster event occures; CF_ENTITY_LIFECYCLE_MSG, // Sent on CF/Device/Asset create/update/delete; + CF_ENTITY_ACTION_EVENT_MSG, + CF_ALARM_ACTION_MSG, CF_TELEMETRY_MSG, // Sent from queue to actor system; CF_LINKED_TELEMETRY_MSG, // Sent from queue to actor system; @@ -149,8 +152,10 @@ public enum MsgType { CF_ENTITY_INIT_CF_MSG, CF_ENTITY_DELETE_MSG, - CF_DYNAMIC_ARGUMENTS_REFRESH_MSG, - CF_ENTITY_DYNAMIC_ARGUMENTS_REFRESH_MSG; + CF_RELATION_ACTION_MSG, + + CF_ARGUMENT_RESET_MSG, // Sent to reset argument; + CF_REEVALUATE_MSG; @Getter private final boolean ignoreOnStart; diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java index 1d8d9497a9..7c9f51a3ef 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java @@ -41,9 +41,6 @@ import java.util.Objects; import java.util.UUID; import java.util.concurrent.CopyOnWriteArrayList; -/** - * Created by ashvayka on 13.01.18. - */ @Data @Slf4j public final class TbMsg implements Serializable { @@ -500,11 +497,11 @@ public final class TbMsg implements Serializable { public String toString() { return "TbMsg.TbMsgBuilder(queueName=" + this.queueName + ", id=" + this.id + ", ts=" + this.ts + - ", type=" + this.type + ", internalType=" + this.internalType + ", originator=" + this.originator + - ", customerId=" + this.customerId + ", metaData=" + this.metaData + ", dataType=" + this.dataType + - ", data=" + this.data + ", ruleChainId=" + this.ruleChainId + ", ruleNodeId=" + this.ruleNodeId + - ", correlationId=" + this.correlationId + ", partition=" + this.partition + ", previousCalculatedFields=" + this.previousCalculatedFieldIds + - ", ctx=" + this.ctx + ", callback=" + this.callback + ")"; + ", type=" + this.type + ", internalType=" + this.internalType + ", originator=" + this.originator + + ", customerId=" + this.customerId + ", metaData=" + this.metaData + ", dataType=" + this.dataType + + ", data=" + this.data + ", ruleChainId=" + this.ruleChainId + ", ruleNodeId=" + this.ruleNodeId + + ", correlationId=" + this.correlationId + ", partition=" + this.partition + ", previousCalculatedFields=" + this.previousCalculatedFieldIds + + ", ctx=" + this.ctx + ", callback=" + this.callback + ")"; } } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/ToCalculatedFieldSystemMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/ToCalculatedFieldSystemMsg.java index c05c0f121e..869ad659ac 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/ToCalculatedFieldSystemMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/ToCalculatedFieldSystemMsg.java @@ -16,12 +16,7 @@ package org.thingsboard.server.common.msg; import org.thingsboard.server.common.msg.aware.TenantAwareMsg; -import org.thingsboard.server.common.msg.queue.TbCallback; public interface ToCalculatedFieldSystemMsg extends TenantAwareMsg { - default TbCallback getCallback() { - return TbCallback.EMPTY; - } - } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/aware/TenantAwareMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/aware/TenantAwareMsg.java index 4161940398..54ad749ceb 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/aware/TenantAwareMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/aware/TenantAwareMsg.java @@ -17,9 +17,14 @@ package org.thingsboard.server.common.msg.aware; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.common.msg.queue.TbCallback; public interface TenantAwareMsg extends TbActorMsg { TenantId getTenantId(); - + + default TbCallback getCallback() { + return TbCallback.EMPTY; + } + } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java index d57301fd10..23b9fe08e3 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/plugin/ComponentLifecycleMsg.java @@ -46,14 +46,15 @@ public class ComponentLifecycleMsg implements TenantAwareMsg, ToAllNodesMsg { private final String name; private final EntityId oldProfileId; private final EntityId profileId; + private final boolean ownerChanged; private final JsonNode info; public ComponentLifecycleMsg(TenantId tenantId, EntityId entityId, ComponentLifecycleEvent event) { - this(tenantId, entityId, event, null, null, null, null, null); + this(tenantId, entityId, event, null, null, null, null, false, null); } @Builder - private ComponentLifecycleMsg(TenantId tenantId, EntityId entityId, ComponentLifecycleEvent event, String oldName, String name, EntityId oldProfileId, EntityId profileId, JsonNode info) { + private ComponentLifecycleMsg(TenantId tenantId, EntityId entityId, ComponentLifecycleEvent event, String oldName, String name, EntityId oldProfileId, EntityId profileId, boolean ownerChanged, JsonNode info) { this.tenantId = tenantId; this.entityId = entityId; this.event = event; @@ -61,6 +62,7 @@ public class ComponentLifecycleMsg implements TenantAwareMsg, ToAllNodesMsg { this.name = name; this.oldProfileId = oldProfileId; this.profileId = profileId; + this.ownerChanged = ownerChanged; this.info = info; } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/rule/engine/DeviceAttributesEventNotificationMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/rule/engine/DeviceAttributesEventNotificationMsg.java index efee3dcb97..f647714c63 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/rule/engine/DeviceAttributesEventNotificationMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/rule/engine/DeviceAttributesEventNotificationMsg.java @@ -23,16 +23,15 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.ToDeviceActorNotificationMsg; +import java.io.Serial; import java.util.HashSet; import java.util.List; import java.util.Set; -/** - * @author Andrew Shvayka - */ @Data public class DeviceAttributesEventNotificationMsg implements ToDeviceActorNotificationMsg { + @Serial private static final long serialVersionUID = 2422071590415277039L; private final TenantId tenantId; @@ -56,4 +55,5 @@ public class DeviceAttributesEventNotificationMsg implements ToDeviceActorNotifi public MsgType getMsgType() { return MsgType.DEVICE_ATTRIBUTES_UPDATE_TO_DEVICE_ACTOR_MSG; } + } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/SchedulerUtils.java b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/SchedulerUtils.java index e0805c27aa..5b8a241a1a 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/SchedulerUtils.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/SchedulerUtils.java @@ -60,16 +60,4 @@ public class SchedulerUtils { return LocalDate.now(UTC).with(TemporalAdjusters.firstDayOfNextMonth()).atStartOfDay(zoneId).toInstant().toEpochMilli(); } - public static long getStartOfNextNextMonth() { - return getStartOfNextNextMonth(UTC); - } - - public static long getStartOfNextNextMonth(ZoneId zoneId) { - return LocalDate.now(UTC).with(firstDayOfNextNextMonth()).atStartOfDay(zoneId).toInstant().toEpochMilli(); - } - - public static TemporalAdjuster firstDayOfNextNextMonth() { - return (temporal) -> temporal.with(DAY_OF_MONTH, 1).plus(2, MONTHS); - } - } diff --git a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java index f5a07cf07e..26a64c7f8a 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java @@ -129,6 +129,7 @@ public class ProtoUtils { builder.setOldProfileIdMSB(msg.getOldProfileId().getId().getMostSignificantBits()); builder.setOldProfileIdLSB(msg.getOldProfileId().getId().getLeastSignificantBits()); } + builder.setOwnerChanged(msg.isOwnerChanged()); if (msg.getName() != null) { builder.setName(msg.getName()); } @@ -165,6 +166,7 @@ public class ProtoUtils { var profileType = EntityType.DEVICE.equals(entityId.getEntityType()) ? EntityType.DEVICE_PROFILE : EntityType.ASSET_PROFILE; builder.oldProfileId(EntityIdFactory.getByTypeAndUuid(profileType, new UUID(proto.getOldProfileIdMSB(), proto.getOldProfileIdLSB()))); } + builder.ownerChanged(proto.getOwnerChanged()); if (proto.hasInfo()) { builder.info(JacksonUtil.toJsonNode(proto.getInfo())); } diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index a05fdd5d36..5f33524464 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -62,7 +62,7 @@ enum EntityTypeProto { MOBILE_APP = 37; MOBILE_APP_BUNDLE = 38; CALCULATED_FIELD = 39; - CALCULATED_FIELD_LINK = 40; + // CALCULATED_FIELD_LINK = 40; - was removed in 4.3 JOB = 41; ADMIN_SETTINGS = 42; AI_MODEL = 43; @@ -888,6 +888,7 @@ message SingleValueArgumentProto { string argName = 1; TsValueProto value = 2; int64 version = 3; + EntityIdProto entityId = 4; } message TsDoubleValProto { @@ -921,6 +922,9 @@ message CalculatedFieldStateProto { repeated SingleValueArgumentProto singleValueArguments = 3; repeated TsRollingArgumentProto rollingValueArguments = 4; repeated GeofencingArgumentProto geofencingArguments = 5; + AlarmStateProto alarmState = 6; + int64 lastArgsUpdateTs = 7; + int64 lastMetricsEvalTs = 8; } //Used to report session state to tb-Service and persist this state in the cache on the tb-Service level. @@ -1273,6 +1277,8 @@ enum ComponentLifecycleEvent { DELETED = 6; FAILED = 7; DEACTIVATED = 8; + RELATION_UPDATED = 9; + RELATION_DELETED = 10; } message ComponentLifecycleMsgProto { @@ -1290,6 +1296,7 @@ message ComponentLifecycleMsgProto { int64 profileIdMSB = 11; int64 profileIdLSB = 12; optional string info = 13; + bool ownerChanged = 100; } message EdgeEventMsgProto { @@ -1721,6 +1728,7 @@ message ToEdgeEventNotificationMsg { message ToCalculatedFieldMsg { CalculatedFieldTelemetryMsgProto telemetryMsg = 1; CalculatedFieldLinkedTelemetryMsgProto linkedTelemetryMsg = 2; + EntityActionEventProto eventMsg = 3; } message ToCalculatedFieldNotificationMsg { @@ -1894,3 +1902,22 @@ message JobStatsMsg { message TaskResultProto { string value = 1; } + +message EntityActionEventProto { + EntityIdProto tenantId = 1; + EntityIdProto entityId = 2; + string entity = 3; + string action = 4; +} + +message AlarmStateProto { + repeated AlarmRuleStateProto createRuleStates = 1; + AlarmRuleStateProto clearRuleState = 2; +} + +message AlarmRuleStateProto { + string severity = 1; + int64 eventCount = 2; + int64 firstEventTs = 3; + int64 lastEventTs = 4; +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java index 7e7de64a5c..04fe2443ef 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java @@ -194,6 +194,11 @@ public abstract class AbstractTbQueueConsumerTemplate i abstract protected void doUnsubscribe(); + @Override + public Set getPartitions() { + return partitions; + } + @Override public List getFullTopicNames() { if (partitions == null) { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/MainQueueConsumerManager.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/MainQueueConsumerManager.java index db5bac7170..b300e8c1b2 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/MainQueueConsumerManager.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/MainQueueConsumerManager.java @@ -25,6 +25,7 @@ import org.thingsboard.server.queue.TbQueueConsumer; import org.thingsboard.server.queue.TbQueueMsg; import org.thingsboard.server.queue.common.consumer.TbQueueConsumerManagerTask.UpdateConfigTask; import org.thingsboard.server.queue.common.consumer.TbQueueConsumerManagerTask.UpdatePartitionsTask; +import org.thingsboard.server.queue.common.consumer.TbQueueConsumerTask.ConsumerKey; import org.thingsboard.server.queue.kafka.TbKafkaConsumerTemplate; import java.util.Collection; @@ -218,7 +219,7 @@ public class MainQueueConsumerManager consumer) { + private void consumerLoop(ConsumerKey consumerKey, TbQueueConsumer consumer) { try { while (!stopped && !consumer.isStopped()) { try { @@ -250,7 +251,7 @@ public class MainQueueConsumerManager msgs, TbQueueConsumer consumer, Object consumerKey, C config) throws Exception { + protected void processMsgs(List msgs, TbQueueConsumer consumer, ConsumerKey consumerKey, C config) throws Exception { log.trace("Processing {} messages", msgs.size()); msgPackProcessor.process(msgs, consumer, consumerKey, config); log.trace("Processed {} messages", msgs.size()); @@ -273,7 +274,7 @@ public class MainQueueConsumerManager { - void process(List msgs, TbQueueConsumer consumer, Object consumerKey, C config) throws Exception; + void process(List msgs, TbQueueConsumer consumer, ConsumerKey consumerKey, C config) throws Exception; } public interface ConsumerWrapper { @@ -285,6 +286,7 @@ public class MainQueueConsumerManager { + private final Map> consumers = new HashMap<>(); @Override @@ -307,8 +309,7 @@ public class MainQueueConsumerManager partitions, Consumer onStop, Function startOffsetProvider) { partitions.forEach(tpi -> { - Integer partitionId = tpi.getPartition().orElse(-1); - String key = queueKey + "-" + partitionId; + ConsumerKey key = new ConsumerKey(queueKey, tpi); Runnable callback = onStop != null ? () -> onStop.accept(tpi) : null; TbQueueConsumerTask consumer = new TbQueueConsumerTask<>(key, () -> { @@ -328,9 +329,11 @@ public class MainQueueConsumerManager> getConsumers() { return consumers.values(); } + } class SingleConsumerWrapper implements ConsumerWrapper { + private TbQueueConsumerTask consumer; @Override @@ -346,7 +349,7 @@ public class MainQueueConsumerManager(queueKey, () -> consumerCreator.apply(config, null), null); // no partitionId passed + consumer = new TbQueueConsumerTask<>(new ConsumerKey(queueKey, null), () -> consumerCreator.apply(config, null), null); // no partitionId passed } consumer.subscribe(partitions); if (!consumer.isRunning()) { @@ -361,5 +364,7 @@ public class MainQueueConsumerManager { @Getter - private final Object key; + private final ConsumerKey key; private volatile TbQueueConsumer consumer; private volatile Supplier> consumerSupplier; @Getter @@ -41,7 +41,7 @@ public class TbQueueConsumerTask { @Setter private Future task; - public TbQueueConsumerTask(Object key, Supplier> consumerSupplier, Runnable callback) { + public TbQueueConsumerTask(ConsumerKey key, Supplier> consumerSupplier, Runnable callback) { this.key = key; this.consumer = null; this.consumerSupplier = consumerSupplier; @@ -97,4 +97,18 @@ public class TbQueueConsumerTask { return task != null; } + public record ConsumerKey(Object queueKey, TopicPartitionInfo partition) { + + @Override + public String toString() { + if (partition != null) { + Integer partitionId = partition.getPartition().orElse(-1); + return queueKey + "-" + partitionId; + } else { + return queueKey.toString(); + } + } + + } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/state/DefaultQueueStateService.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/DefaultQueueStateService.java index be379fb76d..6bcc87af38 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/state/DefaultQueueStateService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/DefaultQueueStateService.java @@ -15,10 +15,15 @@ */ package org.thingsboard.server.queue.common.state; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.queue.TbQueueMsg; import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; +import org.thingsboard.server.queue.discovery.QueueKey; import java.util.Collections; +import java.util.Set; + +import static org.thingsboard.server.common.msg.queue.TopicPartitionInfo.withTopic; public class DefaultQueueStateService extends QueueStateService { @@ -26,4 +31,18 @@ public class DefaultQueueStateService partitions, RestoreCallback callback) { + if (callback != null) { + for (TopicPartitionInfo partition : partitions) { + callback.onPartitionRestored(partition); + } + callback.onAllPartitionsRestored(); + } + eventConsumer.addPartitions(partitions); + for (PartitionedQueueConsumerManager consumer : otherConsumers) { + consumer.addPartitions(withTopic(partitions, consumer.getTopic())); + } + } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/state/KafkaQueueStateService.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/KafkaQueueStateService.java index 2a38c9a86c..60cfe4c98f 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/state/KafkaQueueStateService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/KafkaQueueStateService.java @@ -50,7 +50,7 @@ public class KafkaQueueStateService } @Override - protected void addPartitions(QueueKey queueKey, Set partitions, Runnable whenAllProcessed) { + protected void addPartitions(QueueKey queueKey, Set partitions, RestoreCallback callback) { Map eventsStartOffsets = eventsStartOffsetsProvider != null ? eventsStartOffsetsProvider.get() : null; // remembering the offsets before subscribing to states Set statePartitions = withTopic(partitions, stateConsumer.getTopic()); @@ -61,10 +61,13 @@ public class KafkaQueueStateService try { partitionsInProgress.remove(statePartition); log.info("Finished partition {} (still in progress: {})", statePartition, partitionsInProgress); + if (callback != null) { + callback.onPartitionRestored(statePartition); + } if (partitionsInProgress.isEmpty()) { log.info("All partitions processed"); - if (whenAllProcessed != null) { - whenAllProcessed.run(); + if (callback != null) { + callback.onAllPartitionsRestored(); } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/state/QueueStateService.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/QueueStateService.java index e58d5eb036..e98e8dd7e4 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/state/QueueStateService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/state/QueueStateService.java @@ -49,7 +49,7 @@ public abstract class QueueStateService newPartitions, Runnable whenAllProcessed) { + public void update(QueueKey queueKey, Set newPartitions, RestoreCallback callback) { newPartitions = withTopic(newPartitions, eventConsumer.getTopic()); var writeLock = partitionsLock.writeLock(); writeLock.lock(); @@ -71,23 +71,15 @@ public abstract class QueueStateService partitions, Runnable whenAllProcessed) { - if (whenAllProcessed != null) { - whenAllProcessed.run(); - } - eventConsumer.addPartitions(partitions); - for (PartitionedQueueConsumerManager consumer : otherConsumers) { - consumer.addPartitions(withTopic(partitions, consumer.getTopic())); - } - } + protected abstract void addPartitions(QueueKey queueKey, Set partitions, RestoreCallback callback) ; protected void removePartitions(QueueKey queueKey, Set partitions) { eventConsumer.removePartitions(partitions); @@ -122,4 +114,12 @@ public abstract class QueueStateService implements TbQueueCon return stopped; } + @Override + public Set getPartitions() { + return partitions; + } + @Override public List getFullTopicNames() { return partitions.stream().map(TopicPartitionInfo::getFullTopicName).collect(Collectors.toList()); diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java index 072a17835d..39a32310a6 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java @@ -264,6 +264,8 @@ public class TbUtils { float.class, int.class))); parserConfig.addImport("toInt", new MethodStub(TbUtils.class.getMethod("toInt", double.class))); + parserConfig.addImport("roundResult", new MethodStub(TbUtils.class.getMethod("roundResult", + double.class, Integer.class))); parserConfig.addImport("isNaN", new MethodStub(TbUtils.class.getMethod("isNaN", double.class))); parserConfig.addImport("hexToBytes", new MethodStub(TbUtils.class.getMethod("hexToBytes", @@ -1186,6 +1188,16 @@ public class TbUtils { return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).intValue(); } + public static Object roundResult(double value, Integer precision) { + if (precision == null) { + return value; + } + if (precision.equals(0)) { + return toInt(value); + } + return toFixed(value, precision); + } + public static boolean isNaN(double value) { return Double.isNaN(value); } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java index 73a2183564..4f2719fb75 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java @@ -27,7 +27,9 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; @JsonSubTypes({ @JsonSubTypes.Type(value = TbelCfSingleValueArg.class, name = "SINGLE_VALUE"), @JsonSubTypes.Type(value = TbelCfTsRollingArg.class, name = "TS_ROLLING"), - @JsonSubTypes.Type(value = TbelCfTsGeofencingArg.class, name = "GEOFENCING_CF_ARGUMENT_VALUE"), + @JsonSubTypes.Type(value = TbelCfGeofencingArg.class, name = "GEOFENCING_CF_ARGUMENT_VALUE"), + @JsonSubTypes.Type(value = TbelCfPropagationArg.class, name = "PROPAGATION_CF_ARGUMENT_VALUE"), + @JsonSubTypes.Type(value = TbelCfRelatedEntitiesArgumentValue.class, name = "RELATED_ENTITIES_ARGUMENT_VALUE") }) public interface TbelCfArg extends TbelCfObject { diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsGeofencingArg.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfGeofencingArg.java similarity index 89% rename from common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsGeofencingArg.java rename to common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfGeofencingArg.java index f1e8ec16db..0fa0f4a5bf 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsGeofencingArg.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfGeofencingArg.java @@ -20,12 +20,12 @@ import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data -public class TbelCfTsGeofencingArg implements TbelCfArg { +public class TbelCfGeofencingArg implements TbelCfArg { private final Object value; @JsonCreator - public TbelCfTsGeofencingArg(@JsonProperty("value") Object value) { + public TbelCfGeofencingArg(@JsonProperty("value") Object value) { this.value = value; } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfPropagationArg.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfPropagationArg.java new file mode 100644 index 0000000000..83d7e81a86 --- /dev/null +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfPropagationArg.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.script.api.tbel; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class TbelCfPropagationArg implements TbelCfArg { + + private final Object value; + + @JsonCreator + public TbelCfPropagationArg(@JsonProperty("value") Object value) { + this.value = value; + } + + @Override + public String getType() { + return "PROPAGATION_CF_ARGUMENT_VALUE"; + } + + @Override + public long memorySize() { + return OBJ_SIZE; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldLinkId.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfRelatedEntitiesArgumentValue.java similarity index 50% rename from common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldLinkId.java rename to common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfRelatedEntitiesArgumentValue.java index 6a0c680bb6..02d641d576 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldLinkId.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfRelatedEntitiesArgumentValue.java @@ -13,33 +13,33 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.data.id; +package org.thingsboard.script.api.tbel; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import io.swagger.v3.oas.annotations.media.Schema; -import org.thingsboard.server.common.data.EntityType; +import lombok.Data; +import java.util.Collections; +import java.util.Map; import java.util.UUID; -@Schema -public class CalculatedFieldLinkId extends UUIDBased implements EntityId { +@Data +public class TbelCfRelatedEntitiesArgumentValue implements TbelCfArg { - private static final long serialVersionUID = 1L; + private final Map entityInputs; @JsonCreator - public CalculatedFieldLinkId(@JsonProperty("id") UUID id) { - super(id); + public TbelCfRelatedEntitiesArgumentValue(@JsonProperty("entityInputs") Map values) { + this.entityInputs = Collections.unmodifiableMap(values); } - public static CalculatedFieldLinkId fromString(String calculatedFieldLinkId) { - return new CalculatedFieldLinkId(UUID.fromString(calculatedFieldLinkId)); + @Override + public String getType() { + return "RELATED_ENTITIES_ARGUMENT_VALUE"; } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "CALCULATED_FIELD_LINK", allowableValues = "CALCULATED_FIELD_LINK") @Override - public EntityType getEntityType() { - return EntityType.CALCULATED_FIELD_LINK; + public long memorySize() { + return OBJ_SIZE; } - } diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java index 4dcbd2d69c..38e69246c5 100644 --- a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java @@ -1154,6 +1154,13 @@ public class TbUtilsTest { Assertions.assertEquals(28, TbUtils.toInt(28.0)); } + @Test + public void roundResult() { + Assertions.assertEquals(1729.1729, TbUtils.roundResult(doubleVal, null)); + Assertions.assertEquals(1729, TbUtils.roundResult(doubleVal, 0)); + Assertions.assertEquals(1729.17, TbUtils.roundResult(doubleVal, 2)); + } + @Test public void isNaN() { assertFalse(TbUtils.isNaN(doubleVal)); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapConfig.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapConfig.java index 517bcabac7..aaca3374a2 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapConfig.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapConfig.java @@ -127,7 +127,9 @@ public class LwM2MBootstrapConfig implements Serializable { private BootstrapConfig.ServerConfig setServerConfig (AbstractLwM2MBootstrapServerCredential serverCredential) { BootstrapConfig.ServerConfig serverConfig = new BootstrapConfig.ServerConfig(); - serverConfig.shortId = serverCredential.getShortServerId(); + if (serverCredential.getShortServerId() != null) { + serverConfig.shortId = serverCredential.getShortServerId(); + } serverConfig.lifetime = serverCredential.getLifetime(); serverConfig.defaultMinPeriod = serverCredential.getDefaultMinPeriod(); serverConfig.notifIfDisabled = serverCredential.isNotifIfDisabled(); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2mDefaultBootstrapSessionManager.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2mDefaultBootstrapSessionManager.java index 516f118630..9adceb2860 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2mDefaultBootstrapSessionManager.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2mDefaultBootstrapSessionManager.java @@ -16,6 +16,7 @@ package org.thingsboard.server.transport.lwm2m.bootstrap.secure; import lombok.extern.slf4j.Slf4j; +import org.eclipse.californium.core.coap.Request; import org.eclipse.leshan.core.peer.IpPeer; import org.eclipse.leshan.core.peer.LwM2mPeer; import org.eclipse.leshan.core.peer.PskIdentity; @@ -112,8 +113,10 @@ public class LwM2mDefaultBootstrapSessionManager extends DefaultBootstrapSession } catch (InvalidConfigurationException e){ log.error("Failed put to lwM2MBootstrapSessionClients by endpoint [{}]", request.getEndpointName(), e); } + String msg = String.format("Bootstrap session started... %s", ((Request) request.getCoapRequest()).getLocalAddress().toString()); + log.warn(String.format("%s: %s", request.getEndpointName(), msg)); this.sendLogs(request.getEndpointName(), - String.format("%s: Bootstrap session started...", LOG_LWM2M_INFO, request.getEndpointName())); + String.format("%s: %s", LOG_LWM2M_INFO, msg)); } return session; } @@ -135,7 +138,7 @@ public class LwM2mDefaultBootstrapSessionManager extends DefaultBootstrapSession session.setModel(modelProvider.getObjectModel(session, tasks.supportedObjects)); // set Requests to Send - log.info("tasks.requestsToSend = [{}]", tasks.requestsToSend); + log.warn("tasks.requestsToSend = [{}]", tasks.requestsToSend); session.setRequests(tasks.requestsToSend); // prepare list where we will store Responses @@ -182,14 +185,16 @@ public class LwM2mDefaultBootstrapSessionManager extends DefaultBootstrapSession session.getResponses().add(response); String msg = String.format("%s: receives success response for: %s %s %s", LOG_LWM2M_INFO, request.getClass().getSimpleName(), request.getPath().toString(), response.toString()); + log.warn(msg); this.sendLogs(bsSession.getEndpoint(), msg); // on success for NOT bootstrap finish request we send next request return BootstrapPolicy.continueWith(nextRequest(bsSession)); } else { // on success for bootstrap finish request we stop the session - this.sendLogs(bsSession.getEndpoint(), - String.format("%s: receives success response for bootstrap finish.", LOG_LWM2M_INFO)); + String msg = String.format("%s: receives success response for bootstrap finish.", LOG_LWM2M_INFO); + log.info(msg); + this.sendLogs(bsSession.getEndpoint(), msg); this.tasksProvider.remove(bsSession.getEndpoint()); return BootstrapPolicy.finished(); } @@ -228,7 +233,9 @@ public class LwM2mDefaultBootstrapSessionManager extends DefaultBootstrapSession @Override public void end(BootstrapSession bsSession) { - this.sendLogs(bsSession.getEndpoint(), String.format("%s: Bootstrap session finished.", LOG_LWM2M_INFO)); + String msg = String.format("%s: Bootstrap session finished.", LOG_LWM2M_INFO); + log.warn(msg); + this.sendLogs(bsSession.getEndpoint(), msg); this.tasksProvider.remove(bsSession.getEndpoint()); } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/store/LwM2MBootstrapConfigStoreTaskProvider.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/store/LwM2MBootstrapConfigStoreTaskProvider.java index f24f068365..ab69632956 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/store/LwM2MBootstrapConfigStoreTaskProvider.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/store/LwM2MBootstrapConfigStoreTaskProvider.java @@ -17,15 +17,12 @@ package org.thingsboard.server.transport.lwm2m.bootstrap.store; import lombok.extern.slf4j.Slf4j; import org.eclipse.leshan.core.link.Link; -import org.eclipse.leshan.core.node.LwM2mObject; import org.eclipse.leshan.core.node.LwM2mPath; import org.eclipse.leshan.core.request.BootstrapDeleteRequest; import org.eclipse.leshan.core.request.BootstrapDiscoverRequest; import org.eclipse.leshan.core.request.BootstrapDownlinkRequest; -import org.eclipse.leshan.core.request.BootstrapReadRequest; import org.eclipse.leshan.core.request.ContentFormat; import org.eclipse.leshan.core.response.BootstrapDiscoverResponse; -import org.eclipse.leshan.core.response.BootstrapReadResponse; import org.eclipse.leshan.core.response.LwM2mResponse; import org.eclipse.leshan.server.bootstrap.BootstrapConfig; import org.eclipse.leshan.server.bootstrap.BootstrapConfigStore; @@ -33,7 +30,6 @@ import org.eclipse.leshan.server.bootstrap.BootstrapSession; import org.eclipse.leshan.server.bootstrap.BootstrapUtil; import org.eclipse.leshan.server.bootstrap.InvalidConfigurationException; -import java.math.BigInteger; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -43,14 +39,18 @@ import java.util.Map; import java.util.Set; import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; -import java.util.stream.Collectors; -import static org.eclipse.leshan.core.model.ResourceModel.Type.OPAQUE; +import static org.eclipse.leshan.core.LwM2mId.ACCESS_CONTROL; +import static org.eclipse.leshan.core.LwM2mId.SECURITY; +import static org.eclipse.leshan.core.LwM2mId.SERVER; import static org.eclipse.leshan.server.bootstrap.BootstrapUtil.toWriteRequest; -import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.BOOTSTRAP_DEFAULT_SHORT_ID_0; +import static org.thingsboard.server.common.data.device.credentials.lwm2m.Lwm2mServerIdentifier.LWM2M_SERVER_MAX; +import static org.thingsboard.server.common.data.device.credentials.lwm2m.Lwm2mServerIdentifier.PRIMARY_LWM2M_SERVER; +import static org.thingsboard.server.common.data.device.credentials.lwm2m.Lwm2mServerIdentifier.isLwm2mServer; @Slf4j public class LwM2MBootstrapConfigStoreTaskProvider implements LwM2MBootstrapTaskProvider { @@ -77,11 +77,11 @@ public class LwM2MBootstrapConfigStoreTaskProvider implements LwM2MBootstrapTask @Override public Tasks getTasks(BootstrapSession session, List previousResponse) { // BootstrapConfig config = store.get(session.getEndpoint(), session.getClientTransportData().getIdentity(), session); - BootstrapConfig config = store.get(session); - if (config == null) { + BootstrapConfig configNew = store.get(session); + if (configNew == null) { return null; } - if (previousResponse == null && shouldStartWithDiscover(config)) { + if (previousResponse == null && shouldStartWithDiscover(configNew)) { Tasks tasks = new Tasks(); tasks.requestsToSend = new ArrayList<>(1); tasks.requestsToSend.add(new BootstrapDiscoverRequest()); @@ -96,47 +96,27 @@ public class LwM2MBootstrapConfigStoreTaskProvider implements LwM2MBootstrapTask tasks.supportedObjects = this.supportedObjects; // handle bootstrap discover response if (previousResponse != null) { - if (previousResponse.get(0) instanceof BootstrapDiscoverResponse) { - BootstrapDiscoverResponse discoverResponse = (BootstrapDiscoverResponse) previousResponse.get(0); + if (previousResponse.get(0) instanceof BootstrapDiscoverResponse discoverResponse) { if (discoverResponse.isSuccess()) { - this.initAfterBootstrapDiscover(discoverResponse); - findSecurityInstanceId(discoverResponse.getObjectLinks(), session.getEndpoint()); - } else { + this.initAfterBootstrapDiscover(discoverResponse); + /// Short Server Ids - in old config + findInstancesIdOldByServerId(discoverResponse, session.getEndpoint()); log.warn( - "Bootstrap Discover return error {} : to continue bootstrap session without autoIdForSecurityObject mode. {}", + "Bootstrap server instance successfully found in Security Object (0) in response {}. Continuing bootstrap session. Session: {}", discoverResponse, session); - } - if (this.lwM2MBootstrapSessionClients.get(session.getEndpoint()).getSecurityInstances().get(BOOTSTRAP_DEFAULT_SHORT_ID_0) == null) { - log.error( - "Unable to find bootstrap server instance in Security Object (0) in response {}: unable to continue bootstrap session with autoIdForSecurityObject mode. {}", + } else { + log.warn( + "Unable to find bootstrap server instance in Security Object (0) in response {}. Continuing bootstrap session with autoIdForSecurityObject mode, ignoring information from discoverResponse. Session: {}", discoverResponse, session); - return null; - } - tasks.requestsToSend = new ArrayList<>(1); - tasks.requestsToSend.add(new BootstrapReadRequest("/1")); - tasks.last = false; - return tasks; - } - BootstrapReadResponse readResponse = (BootstrapReadResponse) previousResponse.get(0); - Integer bootstrapServerIdOld = null; - if (readResponse.isSuccess()) { - findServerInstanceId(readResponse, session.getEndpoint()); - if (this.lwM2MBootstrapSessionClients.get(session.getEndpoint()).getSecurityInstances().size() > 0 && this.lwM2MBootstrapSessionClients.get(session.getEndpoint()).getServerInstances().size() > 0) { - bootstrapServerIdOld = this.findBootstrapServerId(session.getEndpoint()); } - } else { - log.warn( - "Bootstrap ReadResponse return error {} : to continue bootstrap session without find Server Instance Id. {}", - readResponse, session); } // create requests from config - tasks.requestsToSend = this.toRequests(config, - config.contentFormat != null ? config.contentFormat : session.getContentFormat(), - bootstrapServerIdOld, session.getEndpoint()); + tasks.requestsToSend = this.toRequests(configNew, + configNew.contentFormat != null ? configNew.contentFormat : session.getContentFormat(), session.getEndpoint()); } else { // create requests from config - tasks.requestsToSend = BootstrapUtil.toRequests(config, - config.contentFormat != null ? config.contentFormat : session.getContentFormat()); + tasks.requestsToSend = BootstrapUtil.toRequests(configNew, + configNew.contentFormat != null ? configNew.contentFormat : session.getContentFormat()); } return tasks; } @@ -148,81 +128,57 @@ public class LwM2MBootstrapConfigStoreTaskProvider implements LwM2MBootstrapTask /** * "Short Server ID": This Resource MUST be set when the Bootstrap-Server Resource has a value of 'false'. - * The values ID:0 and ID:65535 values MUST NOT be used for identifying the LwM2M Server. - * "Short Server ID": + * "Short Lwm2m Server ID": * - Link Instance (lwm2m Server) hase linkParams with key = "ssid" value = "shortId" (ver lvm2m = 1.1). - * - Link Instance (bootstrap Server) hase not linkParams with key = "ssid" (ver lvm2m = 1.0). + * The values ID:0 values MUST NOT be used for identifying the LwM2M Server only BS. */ - protected void findSecurityInstanceId(Link[] objectLinks, String endpoint) { - log.info("Object after discover: [{}]", objectLinks); - for (Link link : objectLinks) { - if (link.getUriReference().startsWith("/0/")) { - try { - LwM2mPath path = new LwM2mPath(link.getUriReference()); - if (path.isObjectInstance()) { - if (link.getAttributes().get("ssid") != null) { - int serverId = Integer.parseInt(link.getAttributes().get("ssid").getCoreLinkValue()); - if (!lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().containsKey(serverId)) { - lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().put(serverId, path.getObjectInstanceId()); - } else { - log.error("Invalid lwm2mSecurityInstance by [{}]", path.getObjectInstanceId()); - } - lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().put(serverId, path.getObjectInstanceId()); + protected void findInstancesIdOldByServerId(BootstrapDiscoverResponse discoverResponses, String endpoint) { + log.info("Object after discover: [{}]", Arrays.toString(discoverResponses.getObjectLinks())); + for (Link link : discoverResponses.getObjectLinks()) { + LwM2mPath path = new LwM2mPath(link.getUriReference()); + if (path.isObjectInstance()) { + int lwm2mShortServerId = 0; + if (path.getObjectId() == 0) { + if (link.getAttributes().get("ssid") != null) { + lwm2mShortServerId = Integer.parseInt(link.getAttributes().get("ssid").getCoreLinkValue()); + if (validateLwm2mShortServerId(lwm2mShortServerId)) { + this.lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().putIfAbsent(lwm2mShortServerId, path.getObjectInstanceId()); + } else { + log.error("Invalid lwm2mSecurityInstance [{}] by short server id [{}]", path.getObjectInstanceId(), lwm2mShortServerId); + } + } else { + this.lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().putIfAbsent(null, path.getObjectInstanceId()); + } + } else if (path.getObjectId() == 1) { + if (link.getAttributes().get("ssid") != null) { + lwm2mShortServerId = Integer.parseInt(link.getAttributes().get("ssid").getCoreLinkValue()); + if (validateLwm2mShortServerId(lwm2mShortServerId)) { + this.lwM2MBootstrapSessionClients.get(endpoint).getServerInstances().putIfAbsent(lwm2mShortServerId, path.getObjectInstanceId()); } else { - if (!this.lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().containsKey(0)) { - this.lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().put(BOOTSTRAP_DEFAULT_SHORT_ID_0, path.getObjectInstanceId()); - } else { - log.error("Invalid bootstrapSecurityInstance by [{}]", path.getObjectInstanceId()); - } + log.error("Invalid lwm2mServerInstance [{}] by short server id [{}]", path.getObjectInstanceId(), lwm2mShortServerId); } } - } catch (Exception e) { - // ignore if this is not a LWM2M path - log.error("Invalid LwM2MPath starting by \"/0/\""); } } } } - protected void findServerInstanceId(BootstrapReadResponse readResponse, String endpoint) { - try { - ((LwM2mObject) readResponse.getContent()).getInstances().values().forEach(instance -> { - var shId = OPAQUE.equals(instance.getResource(0).getType()) ? new BigInteger((byte[]) instance.getResource(0).getValue()).intValue() : instance.getResource(0).getValue(); - int shortId; - if (shId instanceof Long) { - shortId = ((Long) shId).intValue(); - } else { - shortId = (int) shId; - } - this.lwM2MBootstrapSessionClients.get(endpoint).getServerInstances().put(shortId, instance.getId()); - }); - } catch (Exception e) { - log.error("Failed find Server Instance Id. ", e); - } - } - - protected Integer findBootstrapServerId(String endpoint) { - Integer bootstrapServerIdOld = null; - Map filteredMap = this.lwM2MBootstrapSessionClients.get(endpoint).getServerInstances().entrySet() - .stream().filter(x -> !this.lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().containsKey(x.getKey())) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - if (filteredMap.size() > 0) { - bootstrapServerIdOld = filteredMap.keySet().stream().findFirst().get(); - } - return bootstrapServerIdOld; - } - public BootstrapConfigStore getStore() { return this.store; } private void initAfterBootstrapDiscover(BootstrapDiscoverResponse response) { Link[] links = response.getObjectLinks(); + AtomicReference verDefault = new AtomicReference<>("1.0"); Arrays.stream(links).forEach(link -> { LwM2mPath path = new LwM2mPath(link.getUriReference()); - if (!path.isRoot() && path.getObjectId() < 3) { + if (path.isRoot()) { + if (link.hasAttribute() && link.getAttributes().get("lwm2m") != null) { + verDefault.set(link.getAttributes().get("lwm2m").getValue().toString()); + } + } else if (path.getObjectId() <= ACCESS_CONTROL) { if (path.isObject()) { - String ver = link.getAttributes().get("ver") != null ? link.getAttributes().get("ver").getCoreLinkValue() : "1.0"; + String ver = (link.hasAttribute() && link.getAttributes().get("ver") != null) ? link.getAttributes().get("ver").getCoreLinkValue() : verDefault.get(); this.supportedObjects.put(path.getObjectId(), ver); } } @@ -230,94 +186,124 @@ public class LwM2MBootstrapConfigStoreTaskProvider implements LwM2MBootstrapTask } - public List> toRequests(BootstrapConfig bootstrapConfig, + /** Map => LwM2MBootstrapClientInstanceIds + * 1) Both + * - (Short) Server ID == null bs) + * SECURITY = 0; InstanceId = 0 + * - Short Server ID == 1 - 65534 lwm2m) + * SECURITY = 0; InstanceId = 1 + * SERVER = 1; InstanceId = 0 + * 2) Only BS Server + * - Short Server ID == null bs) + * SECURITY = 0; InstanceId = 0 + * 3) Only Lwm2m Server + * - Short Server ID == 1 - 65534 lwm2m) + * SECURITY = 0; InstanceId = 0 + * SERVER = 1; InstanceId = 0 + * */ + public List> toRequests(BootstrapConfig bootstrapConfigNew, ContentFormat contentFormat, - Integer bootstrapServerIdOld, String endpoint) { + Integer bootstrapSecurityInstanceId = this.lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().get(null) == null ? + -2 : this.lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().get(null); List> requests = new ArrayList<>(); Set pathsDelete = new HashSet<>(); - List> requestsWrite = new ArrayList<>(); - boolean isBsServer = false; - boolean isLwServer = false; - /** Map */ - Map instances = new HashMap<>(); - Integer bootstrapServerIdNew = null; - // handle security - int lwm2mSecurityInstanceId = 0; - int bootstrapSecurityInstanceId = this.lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().get(BOOTSTRAP_DEFAULT_SHORT_ID_0); - for (BootstrapConfig.ServerSecurity security : new TreeMap<>(bootstrapConfig.security).values()) { - if (security.bootstrapServer) { - requestsWrite.add(toWriteRequest(bootstrapSecurityInstanceId, security, contentFormat)); - isBsServer = true; - bootstrapServerIdNew = security.serverId; - instances.put(security.serverId, bootstrapSecurityInstanceId); - } else { - if (lwm2mSecurityInstanceId == bootstrapSecurityInstanceId) { - lwm2mSecurityInstanceId++; - } - requestsWrite.add(toWriteRequest(lwm2mSecurityInstanceId, security, contentFormat)); - instances.put(security.serverId, lwm2mSecurityInstanceId); - isLwServer = true; - if (!isBsServer && this.lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().containsKey(security.serverId) && - lwm2mSecurityInstanceId != this.lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().get(security.serverId)) { - pathsDelete.add("/0/" + this.lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().get(security.serverId)); - } - /** - * If there is an instance in the serverInstances with serverId which we replace in the securityInstances - */ - // find serverId in securityInstances by id (instance) - Integer serverIdOld = null; - for (Map.Entry entry : this.lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().entrySet()) { - if (entry.getValue().equals(lwm2mSecurityInstanceId)) { - serverIdOld = entry.getKey(); - } - } - if (!isBsServer && serverIdOld != null && this.lwM2MBootstrapSessionClients.get(endpoint).getServerInstances().containsKey(serverIdOld)) { - pathsDelete.add("/1/" + this.lwM2MBootstrapSessionClients.get(endpoint).getServerInstances().get(serverIdOld)); - } - lwm2mSecurityInstanceId++; + ConcurrentHashMap> requestsWrite = new ConcurrentHashMap<>(); + + /// handle security & handle + // bootstrap Security new - There can only be one instance of bootstrap at a time. + /// bs: handle security only + for (BootstrapConfig.ServerSecurity security : new TreeMap<>(bootstrapConfigNew.security).values()) { + if (security.bootstrapServer && bootstrapSecurityInstanceId > -1) { + // delete old bootstrap Security + String path = "/" + SECURITY + "/" + bootstrapSecurityInstanceId; + pathsDelete.add(path); + security.serverId = null; + requestsWrite.put(path, toWriteRequest(bootstrapSecurityInstanceId, security, contentFormat)); } } - // handle server - for (Map.Entry server : bootstrapConfig.servers.entrySet()) { - int securityInstanceId = instances.get(server.getValue().shortId); - requestsWrite.add(toWriteRequest(securityInstanceId, server.getValue(), contentFormat)); - if (!isBsServer) { - /** Delete instance if bootstrapServerIdNew not equals bootstrapServerIdOld or securityInstanceBsIdNew not equals serverInstanceBsIdOld */ - if (bootstrapServerIdNew != null && server.getValue().shortId == bootstrapServerIdNew && - (bootstrapServerIdNew != bootstrapServerIdOld || securityInstanceId != this.lwM2MBootstrapSessionClients.get(endpoint).getServerInstances().get(bootstrapServerIdOld))) { - pathsDelete.add("/1/" + this.lwM2MBootstrapSessionClients.get(endpoint).getServerInstances().get(bootstrapServerIdOld)); - /** Delete instance if serverIdNew is present in serverInstances and securityInstanceIdOld by serverIdNew not equals serverInstanceIdOld */ - } else if (this.lwM2MBootstrapSessionClients.get(endpoint).getServerInstances().containsKey(server.getValue().shortId) && - securityInstanceId != this.lwM2MBootstrapSessionClients.get(endpoint).getServerInstances().get(server.getValue().shortId)) { - pathsDelete.add("/1/" + this.lwM2MBootstrapSessionClients.get(endpoint).getServerInstances().get(server.getValue().shortId)); - } + + /** lwm2m servers: Multiple instances of lwm2m servers can run simultaneously by SHORT_ID + if update -> delete and write by InstanceId + if new -> only write with InstanceIdMax++ + */ + + /// lwm2m server: handle security & server + //max Lwm2m Security instance old id if new + int lwm2mSecurityInstanceIdMax = -1; + for (Integer shortId : this.lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().keySet()) { + if (isLwm2mServer(shortId)) { + lwm2mSecurityInstanceIdMax = Math.max( + this.lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().get(shortId), + lwm2mSecurityInstanceIdMax); } } - // handle acl - for (Map.Entry acl : bootstrapConfig.acls.entrySet()) { - requestsWrite.add(toWriteRequest(acl.getKey(), acl.getValue(), contentFormat)); + //max Lwm2m Server instance old id if new + int lwm2mServerInstanceIdMax = -1; + for (Integer shortId : this.lwM2MBootstrapSessionClients.get(endpoint).getServerInstances().keySet()) { + if (isLwm2mServer(shortId)) { + lwm2mServerInstanceIdMax = Math.max( + this.lwM2MBootstrapSessionClients.get(endpoint).getServerInstances().get(shortId), + lwm2mServerInstanceIdMax); + } } - // handle delete - if (isBsServer && isLwServer) { - requests.add(new BootstrapDeleteRequest("/0")); - requests.add(new BootstrapDeleteRequest("/1")); - } else { - pathsDelete.forEach(pathDelete -> requests.add(new BootstrapDeleteRequest(pathDelete))); + // Lwm2m update or new + for (BootstrapConfig.ServerSecurity security : new TreeMap<>(bootstrapConfigNew.security).values()) { + if (!security.bootstrapServer) { + // Security + Integer secureInstanceId = this.lwM2MBootstrapSessionClients.get(endpoint).getSecurityInstances().get(security.serverId); + if (secureInstanceId != null) { + pathsDelete.add("/" + SECURITY + "/" + secureInstanceId); + requestsWrite.put("/" + SECURITY + "/" + secureInstanceId, toWriteRequest(secureInstanceId, security, contentFormat)); + } else { + secureInstanceId = ++lwm2mSecurityInstanceIdMax; + if (bootstrapSecurityInstanceId.equals(secureInstanceId)) { + secureInstanceId = ++lwm2mSecurityInstanceIdMax; + } + requestsWrite.put("/" + SECURITY + "/" + secureInstanceId, toWriteRequest(secureInstanceId, security, contentFormat)); + } + Integer serverInstanceId = this.lwM2MBootstrapSessionClients.get(endpoint).getServerInstances().get(security.serverId); + if (serverInstanceId != null) { + pathsDelete.add("/" + SERVER + "/" + serverInstanceId); + } else { + serverInstanceId = ++lwm2mServerInstanceIdMax; + } + Integer finalServerInstanceId = serverInstanceId; + new TreeMap<>(bootstrapConfigNew.servers).values().stream() + .filter(server -> server.shortId == security.serverId) + .findFirst() + .ifPresent(server -> + requestsWrite.put( + "/" + SERVER + "/" + finalServerInstanceId, + toWriteRequest(finalServerInstanceId, server, contentFormat) + ) + ); + } } - // handle write - if (requestsWrite.size() > 0) { - requests.addAll(requestsWrite); + + /// handle acl + for (Map.Entry acl : bootstrapConfigNew.acls.entrySet()) { + requestsWrite.put("/" + ACCESS_CONTROL + "/" + acl.getKey(), toWriteRequest(acl.getKey(), acl.getValue(), contentFormat)); + } + /// handle delete + pathsDelete.forEach(pathDelete -> requests.add(new BootstrapDeleteRequest(pathDelete))); + + /// handle write + if (!requestsWrite.isEmpty()) { + requests.addAll(requestsWrite.values()); } return (requests); } - private void initSupportedObjectsDefault() { this.supportedObjects = new HashMap<>(); - this.supportedObjects.put(0, "1.1"); - this.supportedObjects.put(1, "1.1"); - this.supportedObjects.put(2, "1.0"); + this.supportedObjects.put(SECURITY, "1.1"); + this.supportedObjects.put(SERVER, "1.1"); + this.supportedObjects.put(ACCESS_CONTROL, "1.0"); + } + + private boolean validateLwm2mShortServerId(int id){ + return id >= PRIMARY_LWM2M_SERVER.getId() && id <= LWM2M_SERVER_MAX.getId(); } @Override diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/store/LwM2MConfigurationChecker.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/store/LwM2MConfigurationChecker.java index aba29aed24..3916849da3 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/store/LwM2MConfigurationChecker.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/store/LwM2MConfigurationChecker.java @@ -21,6 +21,10 @@ import org.eclipse.leshan.server.bootstrap.InvalidConfigurationException; import java.util.Map; +import static org.thingsboard.server.common.data.device.credentials.lwm2m.Lwm2mServerIdentifier.NOT_USED_IDENTIFYING_LWM2M_SERVER_MIN; +import static org.thingsboard.server.common.data.device.credentials.lwm2m.Lwm2mServerIdentifier.NOT_USED_IDENTIFYING_LWM2M_SERVER_MAX; +import static org.thingsboard.server.common.data.device.credentials.lwm2m.Lwm2mServerIdentifier.isNotLwm2mServer; + public class LwM2MConfigurationChecker extends ConfigurationChecker { @Override @@ -74,15 +78,16 @@ public class LwM2MConfigurationChecker extends ConfigurationChecker { * This Resource MUST be set when the Bootstrap-Server Resource has false value. * Specific ID:0 and ID:65535 values MUST NOT be used for identifying the LwM2M Server (Section 6.3 of the LwM2M version 1.0 specification). */ - if (!security.bootstrapServer && (srvCfg.shortId < 1 && srvCfg.shortId > 65534 )) { - throw new InvalidConfigurationException("Specific ID:0 and ID:65535 values MUST NOT be used for identifying the LwM2M Server"); + if (!security.bootstrapServer && isNotLwm2mServer(srvCfg.shortId)) { + throw new InvalidConfigurationException("Specific ID:" + NOT_USED_IDENTIFYING_LWM2M_SERVER_MIN.getId() + " and ID:" + NOT_USED_IDENTIFYING_LWM2M_SERVER_MAX.getId() + " values MUST NOT be used for identifying the LwM2M Server"); } } } protected static BootstrapConfig.ServerSecurity getSecurityEntry(BootstrapConfig config, int shortId) { for (Map.Entry es : config.security.entrySet()) { - if (es.getValue().serverId == shortId) { + if ((es.getValue().serverId == null && shortId == 0) || + (es.getValue().serverId != null && es.getValue().serverId == shortId)) { return es.getValue(); } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java index 795f40aa20..789c8b6aac 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java @@ -20,7 +20,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.eclipse.californium.core.CoapResource; import org.eclipse.californium.core.CoapServer; -import org.eclipse.californium.core.config.CoapConfig; import org.eclipse.californium.elements.config.Configuration; import org.eclipse.californium.scandium.config.DtlsConfig; import org.eclipse.californium.scandium.dtls.cipher.CipherSuite; @@ -196,14 +195,13 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { root = new CoapResource(""); coapServer.add(root); } - root.add(new LwM2mTransportCoapResource(otaPackageDataCache, FIRMWARE_UPDATE_COAP_RESOURCE, serverCoapConfig.get(CoapConfig.PREFERRED_BLOCK_SIZE), serverCoapConfig.get(CoapConfig.MAX_RESOURCE_BODY_SIZE))); - root.add(new LwM2mTransportCoapResource(otaPackageDataCache, SOFTWARE_UPDATE_COAP_RESOURCE,serverCoapConfig.get(CoapConfig.PREFERRED_BLOCK_SIZE), serverCoapConfig.get(CoapConfig.MAX_RESOURCE_BODY_SIZE))); + root.add(new LwM2mTransportCoapResource(otaPackageDataCache, FIRMWARE_UPDATE_COAP_RESOURCE)); + root.add(new LwM2mTransportCoapResource(otaPackageDataCache, SOFTWARE_UPDATE_COAP_RESOURCE)); } return leshanServer; } private void setServerWithCredentials(LeshanServerBuilder builder) { -// private void setServerWithCredentials(LeshanServerBuilder builder) { if (this.config.getSslCredentials() != null) { SslCredentials sslCredentials = this.config.getSslCredentials(); builder.setPublicKey(sslCredentials.getPublicKey()); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportCoapResource.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportCoapResource.java index c46b57fb3f..752b888267 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportCoapResource.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportCoapResource.java @@ -34,21 +34,15 @@ import java.util.concurrent.atomic.AtomicInteger; import static org.thingsboard.server.transport.lwm2m.server.ota.DefaultLwM2MOtaUpdateService.FIRMWARE_UPDATE_COAP_RESOURCE; import static org.thingsboard.server.transport.lwm2m.server.ota.DefaultLwM2MOtaUpdateService.SOFTWARE_UPDATE_COAP_RESOURCE; -import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.calculateSzx; @Slf4j public class LwM2mTransportCoapResource extends AbstractLwM2mTransportResource { - private final ConcurrentMap tokenToObserveRelationMap = new ConcurrentHashMap<>(); private final ConcurrentMap tokenToObserveNotificationSeqMap = new ConcurrentHashMap<>(); private final OtaPackageDataCache otaPackageDataCache; - private final int chunkSize; - private final int maxResourceBodySize; - public LwM2mTransportCoapResource(OtaPackageDataCache otaPackageDataCache, String name, int chunkSize, int maxResourceBodySize) { + public LwM2mTransportCoapResource(OtaPackageDataCache otaPackageDataCache, String name) { super(name); this.otaPackageDataCache = otaPackageDataCache; - this.chunkSize = chunkSize; - this.maxResourceBodySize = maxResourceBodySize; this.setObservable(true); // enable observing this.addObserver(new CoapResourceObserver()); } @@ -141,29 +135,23 @@ public class LwM2mTransportCoapResource extends AbstractLwM2mTransportResource { String idStr = exchange.getRequestOptions().getUriPath().get(exchange.getRequestOptions().getUriPath().size() - 1 ); UUID currentId = UUID.fromString(idStr); - log.info("Start Read ota data (path): [{}]", exchange.getRequestOptions().getUriPath().toString()); Response response = new Response(CoAP.ResponseCode.CONTENT); byte[] otaData = this.getOtaData(currentId); if (otaData != null && otaData.length > 0) { - if (otaData.length <= this.maxResourceBodySize) { - log.info("Read ota data (length): [{}]", otaData.length); - response.setPayload(otaData); - int chunkSize = calculateSzx(this.chunkSize); - if (exchange.getRequestOptions().hasBlock2()) { - chunkSize = exchange.getRequestOptions().getBlock2().getSzx(); - } else if (exchange.getRequestOptions().hasBlock1()) { - chunkSize = exchange.getRequestOptions().getBlock1().getSzx(); - } - log.info("With block2 Send currentId: [{}], length: [{}], chunkSize [{}], moreFlag [{}]", currentId.toString(), otaData.length, chunkSize, false); - boolean lastFlag = otaData.length <= this.chunkSize; - response.getOptions().setBlock2(chunkSize, lastFlag, 0); - response.setType(CoAP.Type.CON); - exchange.respond(response); + log.debug("Read ota data (length): [{}]", otaData.length); + response.setPayload(otaData); + if (exchange.getRequestOptions().getBlock2() != null) { + int szx = exchange.getRequestOptions().getBlock2().getSzx(); + int chunkSize = exchange.getRequestOptions().getBlock2().getSize(); + boolean lastFlag = otaData.length <= chunkSize; + response.getOptions().setBlock2(szx, lastFlag, 0); + log.trace("With block2 Send currentId: [{}], length: [{}], chunkSize [{}], szx [{}], moreFlag [{}]", currentId, otaData.length, chunkSize, szx, lastFlag); } else { - log.info("Ota package size: [{}] is larger than server's MAX_RESOURCE_BODY_SIZE [{}]", otaData.length, this.maxResourceBodySize); + log.trace("With block1 Send currentId: [{}], length: [{}], ", currentId, otaData.length); } + exchange.respond(response); } else { - log.info("Ota packaged currentId: [{}] is not found.", currentId.toString()); + log.trace("Ota packaged currentId: [{}] is not found.", currentId); } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServerHelper.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServerHelper.java index ea3358de60..416547ef36 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServerHelper.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServerHelper.java @@ -38,6 +38,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.time.Instant; import java.util.ArrayList; +import java.util.Date; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicLong; @@ -180,8 +181,16 @@ public class LwM2mTransportServerHelper { case BOOLEAN: kvProto.setType(BOOLEAN_V).setBoolV((Boolean) value).build(); break; - case STRING: case TIME: + if (value instanceof Date) { + kvProto.setType(TransportProtos.KeyValueType.LONG_V).setLongV(((Date) value).getTime()); + } else if (value instanceof Integer || value instanceof Long) { + kvProto.setType(TransportProtos.KeyValueType.LONG_V).setLongV((long) (value)); + } else { + kvProto.setType(TransportProtos.KeyValueType.STRING_V).setStringV(value.toString()); + } + break; + case STRING: case OPAQUE: case OBJLNK: kvProto.setType(TransportProtos.KeyValueType.STRING_V).setStringV((String) value); 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 64597df9c5..884ba7e033 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 @@ -66,7 +66,9 @@ import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; import static org.thingsboard.server.common.data.lwm2m.LwM2mConstants.LWM2M_SEPARATOR_PATH; +import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.BOOTSTRAP_TRIGGER_PARAMS_ID; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.LWM2M_OBJECT_VERSION_DEFAULT; +import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.REGISTRATION_TRIGGER_PARAMS_ID; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.convertMultiResourceValuesFromRpcBody; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.equalsResourceTypeGetSimpleName; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.fromVersionedIdToObjectId; @@ -342,6 +344,10 @@ public class LwM2mClient { public String isValidObjectVersion(String path) { LwM2mPath pathIds = getLwM2mPathFromString(path); + if (pathIds.isResource() && (pathIds.toString().equals(REGISTRATION_TRIGGER_PARAMS_ID ) || + pathIds.toString().equals(BOOTSTRAP_TRIGGER_PARAMS_ID))) { + return ""; + } 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()); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/composite/TbLwM2MObserveCompositeCallback.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/composite/TbLwM2MObserveCompositeCallback.java index 660cf303f0..54164abacc 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/composite/TbLwM2MObserveCompositeCallback.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/composite/TbLwM2MObserveCompositeCallback.java @@ -23,6 +23,8 @@ import org.thingsboard.server.transport.lwm2m.server.downlink.TbLwM2MUplinkTarge import org.thingsboard.server.transport.lwm2m.server.log.LwM2MTelemetryLogService; import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler; +import java.util.concurrent.CountDownLatch; + @Slf4j public class TbLwM2MObserveCompositeCallback extends TbLwM2MUplinkTargetedCallback { 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 431b623da9..b7e56c139a 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 @@ -94,6 +94,8 @@ import org.thingsboard.server.transport.lwm2m.server.downlink.TbLwM2MWriteAttrib import org.thingsboard.server.transport.lwm2m.server.downlink.TbLwM2MWriteAttributesRequest; import org.thingsboard.server.transport.lwm2m.server.downlink.composite.TbLwM2MObserveCompositeCallback; import org.thingsboard.server.transport.lwm2m.server.downlink.composite.TbLwM2MObserveCompositeRequest; +import org.thingsboard.server.transport.lwm2m.server.downlink.composite.TbLwM2MReadCompositeCallback; +import org.thingsboard.server.transport.lwm2m.server.downlink.composite.TbLwM2MReadCompositeRequest; import org.thingsboard.server.transport.lwm2m.server.log.LwM2MTelemetryLogService; import org.thingsboard.server.transport.lwm2m.server.model.LwM2MModelConfig; import org.thingsboard.server.transport.lwm2m.server.model.LwM2MModelConfigService; @@ -222,8 +224,11 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl log.info("[{}] Closing old session: {}", registration.getEndpoint(), new UUID(oldSessionInfo.get().getSessionIdMSB(), oldSessionInfo.get().getSessionIdLSB())); sessionManager.deregister(oldSessionInfo.get()); } - logService.log(lwM2MClient, LOG_LWM2M_INFO + ": Client registered with registration id: " + registration.getId() + " version: " - + registration.getLwM2mVersion() + " and modes: " + registration.getQueueMode() + ", " + registration.getBindingMode()); + String msgLogService = String.format(""" + %s: Endpoint [%s] Client registered with registration id: [%s] LwM2mVersion: [%s], SupportedObjectIdVer [%s] QueueMode [%s], BindingMode %s + """, LOG_LWM2M_INFO, registration.getEndpoint(), registration.getId(), registration.getLwM2mVersion(), registration.getSupportedObject(), registration.getQueueMode(), registration.getBindingMode()); + logService.log(lwM2MClient, msgLogService); + log.debug(msgLogService); sessionManager.register(lwM2MClient.getSession()); this.initClientTelemetry(lwM2MClient); this.initAttributes(lwM2MClient, true); @@ -242,7 +247,7 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl logService.log(lwM2MClient, LOG_LWM2M_WARN + ": Client registration failed due to invalid state: " + stateException.getState()); } } catch (Throwable t) { - log.error("[{}] endpoint [{}] error Unable registration.", registration.getEndpoint(), t); + log.error("Endpoint [{}], Error Unable registration: [{}].", registration.getEndpoint(), t.getMessage(), t); logService.log(lwM2MClient, LOG_LWM2M_WARN + ": Client registration failed due to: " + t.getMessage()); } }); @@ -288,7 +293,6 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl clientContext.unregister(client, registration); SessionInfoProto sessionInfo = client.getSession(); if (sessionInfo != null) { - securityStore.remove(client.getEndpoint(), client.getRegistration().getId()); sessionManager.deregister(sessionInfo); sessionStore.remove(registration.getEndpoint()); log.info("Client close session: [{}] unReg [{}] name [{}] profile ", registration.getId(), registration.getEndpoint(), sessionInfo.getDeviceType()); @@ -483,9 +487,9 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl private void initClientTelemetry(LwM2mClient lwM2MClient) { Lwm2mDeviceProfileTransportConfiguration profile = clientContext.getProfile(lwM2MClient.getRegistration()); Set supportedObjects = clientContext.getSupportedIdVerInClient(lwM2MClient); - if (supportedObjects != null && supportedObjects.size() > 0) { - this.sendReadRequests(lwM2MClient, profile, supportedObjects); + if (supportedObjects != null && !supportedObjects.isEmpty()) { this.sendInitObserveRequests(lwM2MClient, profile, supportedObjects); + this.sendReadRequests(lwM2MClient, profile, supportedObjects); this.sendWriteAttributeRequests(lwM2MClient, profile, supportedObjects); // Removed. Used only for debug. // this.sendDiscoverRequests(lwM2MClient, profile, supportedObjects); @@ -495,14 +499,30 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl private void sendReadRequests(LwM2mClient lwM2MClient, Lwm2mDeviceProfileTransportConfiguration profile, Set supportedObjects) { try { Set targetIds = new HashSet<>(profile.getObserveAttr().getAttribute()); + Boolean initAttrTelAsObsStrategy = profile.getObserveAttr().getInitAttrTelAsObsStrategy(); targetIds.addAll(profile.getObserveAttr().getTelemetry()); targetIds = diffSets(profile.getObserveAttr().getObserve(), targetIds); targetIds = targetIds.stream().filter(target -> isSupportedTargetId(supportedObjects, target)).collect(Collectors.toSet()); - - CountDownLatch latch = new CountDownLatch(targetIds.size()); - targetIds.forEach(versionedId -> sendReadRequest(lwM2MClient, versionedId, - new TbLwM2MLatchCallback<>(latch, new TbLwM2MReadCallback(this, logService, lwM2MClient, versionedId)))); - latch.await(config.getTimeout(), TimeUnit.MILLISECONDS); + if (!targetIds.isEmpty()) { + TelemetryObserveStrategy observeStrategy = profile.getObserveAttr().getObserveStrategy(); + long timeoutMs = config.getTimeout(); + if (initAttrTelAsObsStrategy && observeStrategy != SINGLE) { + switch (observeStrategy) { + case COMPOSITE_ALL -> { + sendReadCompositeRequest(lwM2MClient, targetIds.toArray(new String[0])); + } + case COMPOSITE_BY_OBJECT -> { + Map versionedObjectIds = groupByObjectIdVersionedIds(targetIds); + versionedObjectIds.forEach((k, v) -> sendReadCompositeRequest(lwM2MClient, v)); + } + } + } else { + CountDownLatch latch = new CountDownLatch(targetIds.size()); + targetIds.forEach(versionedId -> sendReadSingleRequest(lwM2MClient, versionedId, + new TbLwM2MLatchCallback<>(latch, new TbLwM2MReadCallback(this, logService, lwM2MClient, versionedId)))); + latch.await(timeoutMs, TimeUnit.MILLISECONDS); + } + } } catch (InterruptedException e) { log.error("[{}] Failed to await Read requests!", lwM2MClient.getEndpoint(), e); } catch (Exception e) { @@ -529,17 +549,11 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl if (!completed) log.trace("[{}] Timeout occurred during SINGLE observe init", lwM2MClient.getEndpoint()); } case COMPOSITE_ALL -> { - CountDownLatch latch = new CountDownLatch(targetIds.size()); sendObserveCompositeRequest(lwM2MClient, targetIds.toArray(new String[0])); - boolean completed = latch.await(timeoutMs, TimeUnit.MILLISECONDS); - if (!completed) log.trace("[{}] Timeout occurred during COMPOSITE_ALL observe init", lwM2MClient.getEndpoint()); } case COMPOSITE_BY_OBJECT -> { Map versionedObjectIds = groupByObjectIdVersionedIds(targetIds); - CountDownLatch latch = new CountDownLatch(versionedObjectIds.size()); versionedObjectIds.forEach((k, v) -> sendObserveCompositeRequest(lwM2MClient, v)); - boolean completed = latch.await(timeoutMs, TimeUnit.MILLISECONDS); - if (!completed) log.trace("[{}] Timeout occurred during COMPOSITE_BY_OBJECT observe init", lwM2MClient.getEndpoint()); } } } @@ -562,23 +576,22 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl } } - private void sendReadRequest(LwM2mClient lwM2MClient, String versionedId) { - sendReadRequest(lwM2MClient, versionedId, new TbLwM2MReadCallback(this, logService, lwM2MClient, versionedId)); - } - - private void sendReadRequest(LwM2mClient lwM2MClient, String versionedId, DownlinkRequestCallback callback) { + private void sendReadSingleRequest(LwM2mClient lwM2MClient, String versionedId, DownlinkRequestCallback callback) { TbLwM2MReadRequest request = TbLwM2MReadRequest.builder().versionedId(versionedId).timeout(clientContext.getRequestTimeout(lwM2MClient)).build(); defaultLwM2MDownlinkMsgHandler.sendReadRequest(lwM2MClient, request, callback); } - private void sendObserveRequest(LwM2mClient lwM2MClient, String versionedId) { - sendObserveRequest(lwM2MClient, versionedId, new TbLwM2MObserveCallback(this, logService, lwM2MClient, versionedId)); + private void sendReadCompositeRequest(LwM2mClient lwM2MClient, String[] versionedIds) { + TbLwM2MReadCompositeRequest request = TbLwM2MReadCompositeRequest.builder().versionedIds(versionedIds).timeout(clientContext.getRequestTimeout(lwM2MClient)).build(); + var mainCallback = new TbLwM2MReadCompositeCallback(this, logService, lwM2MClient, versionedIds); + defaultLwM2MDownlinkMsgHandler.sendReadCompositeRequest(lwM2MClient, request, mainCallback ); } private void sendObserveRequest(LwM2mClient lwM2MClient, String versionedId, DownlinkRequestCallback callback) { TbLwM2MObserveRequest request = TbLwM2MObserveRequest.builder().versionedId(versionedId).timeout(clientContext.getRequestTimeout(lwM2MClient)).build(); defaultLwM2MDownlinkMsgHandler.sendObserveRequest(lwM2MClient, request, callback); } + private void sendObserveCompositeRequest(LwM2mClient lwM2MClient, String[] versionedIds) { TbLwM2MObserveCompositeRequest request = TbLwM2MObserveCompositeRequest.builder().versionedIds(versionedIds).timeout(clientContext.getRequestTimeout(lwM2MClient)).build(); @@ -853,7 +866,8 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl ResourceUpdateResult updateResource = new ResourceUpdateResult(lwM2MClient); request.getObjectInstances().forEach(instance -> instance.getResources().forEach((resId, lwM2mResource) ->{ - this.updateResourcesValue(updateResource, lwM2mResource, versionId + "/" + resId, Mode.REPLACE, 0); + String path = versionId.endsWith("/") ? versionId + resId : versionId + "/" + resId; + this.updateResourcesValue(updateResource, lwM2mResource, path, Mode.REPLACE, 0); }) ); clientContext.update(lwM2MClient); 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 160ca3d905..75ac177969 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 @@ -81,7 +81,8 @@ public class LwM2MTransportUtil { public static final String LOG_LWM2M_INFO = "info"; public static final String LOG_LWM2M_ERROR = "error"; public static final String LOG_LWM2M_WARN = "warn"; - public static final int BOOTSTRAP_DEFAULT_SHORT_ID_0 = 0; + public static final String REGISTRATION_TRIGGER_PARAMS_ID = "/1/0/8"; + public static final String BOOTSTRAP_TRIGGER_PARAMS_ID = "/1/0/9";; public static LwM2mOtaConvert convertOtaUpdateValueToString(String pathIdVer, Object value, ResourceModel.Type currentType) { String path = fromVersionedIdToObjectId(pathIdVer); @@ -120,10 +121,6 @@ public class LwM2MTransportUtil { } } - public static List getBootstrapParametersFromThingsboard(DeviceProfile deviceProfile) { - return toLwM2MClientProfile(deviceProfile).getBootstrap(); - } - public static String fromVersionedIdToObjectId(String pathIdVer) { try { if (pathIdVer == null) { @@ -398,13 +395,6 @@ public class LwM2MTransportUtil { } } - public static int calculateSzx(int size) { - if (size < 16 || size > 1024 || (size & (size - 1)) != 0) { - throw new IllegalArgumentException("Size must be a power of 2 between 16 and 1024."); - } - return (int) (Math.log(size / 16) / Math.log(2)); - } - public static ConcurrentHashMap groupByObjectIdVersionedIds(Set targetIds) { return targetIds.stream() .collect(Collectors.groupingBy( diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2mValueConverterImpl.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2mValueConverterImpl.java index 91d55305da..9d496a2f88 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2mValueConverterImpl.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2mValueConverterImpl.java @@ -33,6 +33,7 @@ import java.util.Date; import static org.eclipse.leshan.core.model.ResourceModel.Type.NONE; import static org.eclipse.leshan.core.model.ResourceModel.Type.OPAQUE; +import static org.eclipse.leshan.core.model.ResourceModel.Type.TIME; @Slf4j public class LwM2mValueConverterImpl implements LwM2mValueConverter { @@ -58,7 +59,7 @@ public class LwM2mValueConverterImpl implements LwM2mValueConverter { currentType = OPAQUE; } - if (currentType == expectedType || currentType == NONE) { + if (currentType == expectedType || currentType == NONE || currentType == TIME) { /** expected type */ return value; } @@ -135,7 +136,7 @@ public class LwM2mValueConverterImpl implements LwM2mValueConverter { **/ } catch (IllegalArgumentException e) { log.debug("Unable to convert string to date", e); - throw new CodecException("Unable to convert string (%s) to date for resource %s", value, + throw new CodecException("Unable to convert string (%s) to %s for resource %s", value, TIME.name(), resourcePath); } default: @@ -149,7 +150,7 @@ public class LwM2mValueConverterImpl implements LwM2mValueConverter { case FLOAT: return String.valueOf(value); case TIME: - String DATE_FORMAT = "MMM d, yyyy HH:mm a"; + String DATE_FORMAT = "yyyy-MM-dd[[ ]['T']HH:mm[:ss[.SSS]][ ][XXX][Z][z][VV][O]]"; Long timeValue; try { timeValue = ((Date) value).getTime(); diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index a756b84fe8..867b39cd23 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -46,9 +46,6 @@ import java.security.cert.X509Certificate; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -/** - * Created by valerii.sosliuk on 11/6/16. - */ @Slf4j @Component("MqttSslHandlerProvider") @ConditionalOnProperty(prefix = "transport.mqtt.ssl", value = "enabled", havingValue = "true", matchIfMissing = false) diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 7f9da9e0b9..4397802951 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -133,9 +133,6 @@ import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetr import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopic.parseTopic; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.parseTopicPublish; -/** - * @author Andrew Shvayka - */ @Slf4j public class MqttTransportHandler extends ChannelInboundHandlerAdapter implements GenericFutureListener>, SessionMsgListener { diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java index c630def89f..5d127fccb7 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java @@ -25,9 +25,6 @@ import io.netty.handler.ssl.SslHandler; import org.thingsboard.server.transport.mqtt.limits.IpFilter; import org.thingsboard.server.transport.mqtt.limits.ProxyIpFilter; -/** - * @author Andrew Shvayka - */ public class MqttTransportServerInitializer extends ChannelInitializer { private final MqttTransportContext context; diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java index c7ff8912aa..d62f35d561 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java @@ -35,9 +35,6 @@ import org.thingsboard.server.common.data.TbTransportService; import java.net.InetSocketAddress; -/** - * @author Andrew Shvayka - */ @Service("MqttTransportService") @TbMqttTransportComponent @Slf4j diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java index 64f7e5df4a..61a886a7db 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java @@ -45,10 +45,6 @@ import java.util.UUID; import static org.thingsboard.server.common.data.device.profile.MqttTopics.DEVICE_SOFTWARE_FIRMWARE_RESPONSES_TOPIC_FORMAT; - -/** - * @author Andrew Shvayka - */ @Component @Slf4j public class JsonMqttAdaptor implements MqttTransportAdaptor { @@ -122,7 +118,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { public Optional convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, String deviceName, TransportProtos.GetAttributeResponseMsg responseMsg) throws AdaptorException { return processConvertFromGatewayAttributeResponseMsg(ctx, deviceName, responseMsg); } - + @Override public Optional convertToPublish(MqttDeviceAwareSessionContext ctx, TransportProtos.AttributeUpdateNotificationMsg notificationMsg, String topic) { return Optional.of(createMqttPublishMsg(ctx, topic, JsonConverter.toJson(notificationMsg))); diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java index 012709c31f..356004246f 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java @@ -41,9 +41,6 @@ import org.thingsboard.server.transport.mqtt.session.MqttDeviceAwareSessionConte import java.util.Optional; -/** - * @author Andrew Shvayka - */ public interface MqttTransportAdaptor { ByteBufAllocator ALLOCATOR = new UnpooledByteBufAllocator(false); @@ -90,4 +87,5 @@ public interface MqttTransportAdaptor { payload.writeBytes(payloadInBytes); return new MqttPublishMessage(mqttFixedHeader, header, payload); } + } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/ProtoMqttAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/ProtoMqttAdaptor.java index fd10ad750e..4472bb77d2 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/ProtoMqttAdaptor.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/ProtoMqttAdaptor.java @@ -48,7 +48,7 @@ public class ProtoMqttAdaptor implements MqttTransportAdaptor { public TransportProtos.PostTelemetryMsg convertToPostTelemetry(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { DeviceSessionCtx deviceSessionCtx = (DeviceSessionCtx) ctx; byte[] bytes = toBytes(inbound.payload()); - Descriptors.Descriptor telemetryDynamicMsgDescriptor = ProtoConverter.validateDescriptor(deviceSessionCtx.getTelemetryDynamicMsgDescriptor()); + Descriptors.Descriptor telemetryDynamicMsgDescriptor = ProtoConverter.validateDescriptor(deviceSessionCtx.getTelemetryDynamicMessageDescriptor()); try { return JsonConverter.convertToTelemetryProto(JsonParser.parseString(ProtoConverter.dynamicMsgToJson(bytes, telemetryDynamicMsgDescriptor))); } catch (Exception e) { @@ -228,4 +228,5 @@ public class ProtoMqttAdaptor implements MqttTransportAdaptor { private int getRequestId(String topicName, String topic) { return Integer.parseInt(topicName.substring(topic.length())); } + } 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 8ce0fc5c1e..5bb3c9c28f 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 @@ -33,9 +33,6 @@ import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; import java.util.UUID; import java.util.concurrent.ConcurrentMap; -/** - * Created by ashvayka on 19.01.17. - */ @ToString(callSuper = true) @Slf4j public abstract class AbstractGatewayDeviceSessionContext extends MqttDeviceAwareSessionContext implements SessionMsgListener { diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java index 91e7dedbf2..6542492238 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java @@ -80,6 +80,8 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; @@ -94,9 +96,6 @@ import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConn import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.STATE; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.messageName; -/** - * Created by ashvayka on 19.01.17. - */ @Slf4j public abstract class AbstractGatewaySessionHandler { @@ -114,6 +113,7 @@ public abstract class AbstractGatewaySessionHandler deviceCreationLockMap; + @Getter private final ConcurrentMap devices; private final ConcurrentMap> deviceFutures; protected final ConcurrentMap mqttQoSMap; @@ -405,18 +405,34 @@ public abstract class AbstractGatewaySessionHandler deviceEntry : json.getAsJsonObject().entrySet()) { - if (!deviceEntry.getValue().isJsonArray()) { - log.warn("{}[{}]", CAN_T_PARSE_VALUE, json); - continue; - } + + List> deviceEntries = json.getAsJsonObject().entrySet().stream() + .filter(entry -> { + final boolean isArray = entry.getValue().isJsonArray(); + if (!isArray) { + log.warn("{} device='{}' value={}", CAN_T_PARSE_VALUE, entry.getKey(), entry.getValue()); + } + return isArray; + }) + .toList(); + + if (deviceEntries.isEmpty()) { + log.debug("[{}][{}][{}] Devices telemetry message is empty", gateway.getTenantId(), gateway.getDeviceId(), sessionId); + throw new IllegalArgumentException("[" + sessionId + "] Devices telemetry message is empty for [" + gateway.getDeviceId() + "]"); + } + + AtomicInteger remaining = new AtomicInteger(deviceEntries.size()); + AtomicBoolean ackSent = new AtomicBoolean(false); + + for (Map.Entry deviceEntry : deviceEntries) { String deviceName = deviceEntry.getKey(); - process(deviceName, deviceCtx -> processPostTelemetryMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId), - t -> failedToProcessLog(deviceName, TELEMETRY, t)); + process(deviceName, deviceCtx -> processPostTelemetryMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId, + remaining, ackSent), + t -> processFailure(msgId, deviceName, TELEMETRY, ackSent, t)); } } - private void processPostTelemetryMsg(T deviceCtx, JsonElement msg, String deviceName, int msgId) { + private void processPostTelemetryMsg(T deviceCtx, JsonElement msg, String deviceName, int msgId, AtomicInteger remaining, AtomicBoolean ackSent) { try { long systemTs = System.currentTimeMillis(); TbPair> gatewayPayloadPair = JsonConverter.convertToGatewayTelemetry(msg.getAsJsonArray(), systemTs); @@ -425,10 +441,10 @@ public abstract class AbstractGatewaySessionHandler { String deviceName = checkDeviceName(telemetryMsg.getDeviceName()); - process(deviceName, deviceCtx -> processPostTelemetryMsg(deviceCtx, telemetryMsg.getMsg(), deviceName, msgId), - t -> failedToProcessLog(deviceName, TELEMETRY, t)); + process(deviceName, deviceCtx -> processPostTelemetryMsg(deviceCtx, telemetryMsg.getMsg(), deviceName, msgId, + remaining, ackSent), + t -> processFailure(msgId, deviceName, TELEMETRY, ackSent, t)); }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); } } - protected void processPostTelemetryMsg(MqttDeviceAwareSessionContext deviceCtx, TransportProtos.PostTelemetryMsg msg, String deviceName, int msgId) { + protected void processPostTelemetryMsg(MqttDeviceAwareSessionContext deviceCtx, TransportProtos.PostTelemetryMsg msg, String deviceName, int msgId, + AtomicInteger remaining, AtomicBoolean ackSent) { try { TransportProtos.PostTelemetryMsg postTelemetryMsg = ProtoConverter.validatePostTelemetryMsg(msg.toByteArray()); - transportService.process(deviceCtx.getSessionInfo(), postTelemetryMsg, getPubAckCallback(channel, deviceName, msgId, postTelemetryMsg)); + transportService.process(deviceCtx.getSessionInfo(), postTelemetryMsg, getAggregatePubAckCallback(channel, msgId, deviceName, postTelemetryMsg, remaining, ackSent)); } catch (Throwable e) { log.warn("[{}][{}][{}] Failed to convert telemetry: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, msg, e); - ackOrClose(msgId); + ackOrClose(msgId, ackSent); } } @@ -475,26 +496,42 @@ public abstract class AbstractGatewaySessionHandler deviceEntry : json.getAsJsonObject().entrySet()) { - if (!deviceEntry.getValue().isJsonObject()) { - log.warn("{}[{}]", CAN_T_PARSE_VALUE, json); - continue; - } + List> deviceEntries = json.getAsJsonObject().entrySet().stream() + .filter(entry -> { + boolean isJsonObject = entry.getValue().isJsonObject(); + if (!isJsonObject) { + log.warn("{} device='{}' value={}", CAN_T_PARSE_VALUE, entry.getKey(), entry.getValue()); + } + return isJsonObject; + }) + .toList(); + + if (deviceEntries.isEmpty()) { + log.debug("[{}][{}][{}] Devices claim message is empty", gateway.getTenantId(), gateway.getDeviceId(), sessionId); + throw new IllegalArgumentException("[" + sessionId + "] Devices claim message is empty for [" + gateway.getDeviceId() + "]"); + } + + AtomicInteger remaining = new AtomicInteger(deviceEntries.size()); + AtomicBoolean ackSent = new AtomicBoolean(false); + + for (Map.Entry deviceEntry : deviceEntries) { String deviceName = deviceEntry.getKey(); - process(deviceName, deviceCtx -> processClaimDeviceMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId), - t -> failedToProcessLog(deviceName, CLAIMING, t)); + process(deviceName, deviceCtx -> processClaimDeviceMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId, + remaining, ackSent), + t -> processFailure(msgId, deviceName, CLAIMING, ackSent, t)); } } - private void processClaimDeviceMsg(MqttDeviceAwareSessionContext deviceCtx, JsonElement claimRequest, String deviceName, int msgId) { + private void processClaimDeviceMsg(MqttDeviceAwareSessionContext deviceCtx, JsonElement claimRequest, String deviceName, int msgId, + AtomicInteger remaining, AtomicBoolean ackSent) { try { DeviceId deviceId = deviceCtx.getDeviceId(); TransportProtos.ClaimDeviceMsg claimDeviceMsg = JsonConverter.convertToClaimDeviceProto(deviceId, claimRequest); - transportService.process(deviceCtx.getSessionInfo(), claimDeviceMsg, getPubAckCallback(channel, deviceName, msgId, claimDeviceMsg)); + transportService.process(deviceCtx.getSessionInfo(), claimDeviceMsg, getAggregatePubAckCallback(channel, msgId, deviceName, claimDeviceMsg, remaining, ackSent)); } catch (Throwable e) { log.warn("[{}][{}][{}] Failed to convert claim message: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, claimRequest, e); - ackOrClose(msgId); + ackOrClose(msgId, ackSent); } } @@ -507,49 +544,70 @@ public abstract class AbstractGatewaySessionHandler { String deviceName = checkDeviceName(claimDeviceMsg.getDeviceName()); - process(deviceName, deviceCtx -> processClaimDeviceMsg(deviceCtx, claimDeviceMsg.getClaimRequest(), deviceName, msgId), - t -> failedToProcessLog(deviceName, CLAIMING, t)); + process(deviceName, deviceCtx -> processClaimDeviceMsg(deviceCtx, claimDeviceMsg.getClaimRequest(), deviceName, msgId, + remaining, ackSent), + t -> processFailure(msgId, deviceName, CLAIMING, ackSent, t)); }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); } } - private void processClaimDeviceMsg(MqttDeviceAwareSessionContext deviceCtx, TransportApiProtos.ClaimDevice claimRequest, String deviceName, int msgId) { + private void processClaimDeviceMsg(MqttDeviceAwareSessionContext deviceCtx, TransportApiProtos.ClaimDevice claimRequest, String deviceName, int msgId, + AtomicInteger remaining, AtomicBoolean ackSent) { try { DeviceId deviceId = deviceCtx.getDeviceId(); TransportProtos.ClaimDeviceMsg claimDeviceMsg = ProtoConverter.convertToClaimDeviceProto(deviceId, claimRequest.toByteArray()); - transportService.process(deviceCtx.getSessionInfo(), claimDeviceMsg, getPubAckCallback(channel, deviceName, msgId, claimDeviceMsg)); + transportService.process(deviceCtx.getSessionInfo(), claimDeviceMsg, getAggregatePubAckCallback(channel, msgId, deviceName, claimDeviceMsg, remaining, ackSent)); } catch (Throwable e) { log.warn("[{}][{}][{}] Failed to convert claim message: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, claimRequest, e); - ackOrClose(msgId); + ackOrClose(msgId, ackSent); } } private void onDeviceAttributesJson(int msgId, ByteBuf payload) throws AdaptorException { JsonElement json = JsonMqttAdaptor.validateJsonPayload(sessionId, payload); validateJsonObject(json); - for (Map.Entry deviceEntry : json.getAsJsonObject().entrySet()) { - if (!deviceEntry.getValue().isJsonObject()) { - log.warn("{}[{}]", CAN_T_PARSE_VALUE, json); - continue; - } + List> deviceEntries = json.getAsJsonObject().entrySet().stream() + .filter(entry -> { + boolean isJsonObject = entry.getValue().isJsonObject(); + if (!isJsonObject) { + log.warn("{} device='{}' value={}", CAN_T_PARSE_VALUE, entry.getKey(), entry.getValue()); + } + return isJsonObject; + }) + .toList(); + + if (deviceEntries.isEmpty()) { + log.debug("[{}][{}][{}] Devices attribute message is empty", gateway.getTenantId(), gateway.getDeviceId(), sessionId); + throw new IllegalArgumentException("[" + sessionId + "] Devices attribute message is empty for [" + gateway.getDeviceId() + "]"); + } + + AtomicInteger remaining = new AtomicInteger(deviceEntries.size()); + AtomicBoolean ackSent = new AtomicBoolean(false); + + for (Map.Entry deviceEntry : deviceEntries) { String deviceName = deviceEntry.getKey(); - process(deviceName, deviceCtx -> processPostAttributesMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId), - t -> failedToProcessLog(deviceName, ATTRIBUTE, t)); + process(deviceName, deviceCtx -> processPostAttributesMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId, + remaining, ackSent), + t -> processFailure(msgId, deviceName, ATTRIBUTE, ackSent, t)); } } - private void processPostAttributesMsg(MqttDeviceAwareSessionContext deviceCtx, JsonElement msg, String deviceName, int msgId) { + private void processPostAttributesMsg(MqttDeviceAwareSessionContext deviceCtx, JsonElement msg, String deviceName, int msgId, + AtomicInteger remaining, AtomicBoolean ackSent) { try { TransportProtos.PostAttributeMsg postAttributeMsg = JsonConverter.convertToAttributesProto(msg.getAsJsonObject()); - transportService.process(deviceCtx.getSessionInfo(), postAttributeMsg, getPubAckCallback(channel, deviceName, msgId, postAttributeMsg)); + transportService.process(deviceCtx.getSessionInfo(), postAttributeMsg, getAggregatePubAckCallback(channel, msgId, deviceName, postAttributeMsg, remaining, ackSent)); } catch (Throwable e) { log.warn("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, msg, e); - ackOrClose(msgId); + ackOrClose(msgId, ackSent); } } @@ -562,23 +620,28 @@ public abstract class AbstractGatewaySessionHandler { String deviceName = checkDeviceName(attributesMsg.getDeviceName()); - process(deviceName, deviceCtx -> processPostAttributesMsg(deviceCtx, attributesMsg.getMsg(), deviceName, msgId), - t -> failedToProcessLog(deviceName, ATTRIBUTE, t)); + process(deviceName, deviceCtx -> processPostAttributesMsg(deviceCtx, attributesMsg.getMsg(), deviceName, msgId, + remaining, ackSent), + t -> processFailure(msgId, deviceName, ATTRIBUTE, ackSent, t)); }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); } } - protected void processPostAttributesMsg(MqttDeviceAwareSessionContext deviceCtx, TransportProtos.PostAttributeMsg kvListProto, String deviceName, int msgId) { + protected void processPostAttributesMsg(MqttDeviceAwareSessionContext deviceCtx, TransportProtos.PostAttributeMsg kvListProto, String deviceName, int msgId, + AtomicInteger remaining, AtomicBoolean ackSent) { try { TransportProtos.PostAttributeMsg postAttributeMsg = ProtoConverter.validatePostAttributeMsg(kvListProto); - transportService.process(deviceCtx.getSessionInfo(), postAttributeMsg, getPubAckCallback(channel, deviceName, msgId, postAttributeMsg)); + transportService.process(deviceCtx.getSessionInfo(), postAttributeMsg, getAggregatePubAckCallback(channel, msgId, deviceName, postAttributeMsg, remaining, ackSent)); } catch (Throwable e) { log.warn("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, kvListProto, e); - ackOrClose(msgId); + ackOrClose(msgId, ackSent); } } @@ -647,27 +710,34 @@ public abstract class AbstractGatewaySessionHandler processRpcResponseMsg(deviceCtx, requestId, data, deviceName, msgId), - t -> failedToProcessLog(deviceName, RPC_RESPONSE, t)); + AtomicInteger remaining = new AtomicInteger(1); + AtomicBoolean ackSent = new AtomicBoolean(false); + process(deviceName, deviceCtx -> processRpcResponseMsg(deviceCtx, requestId, data, deviceName, msgId, remaining, ackSent), + t -> processFailure(msgId, deviceName, RPC_RESPONSE, ackSent, t)); } - private void processRpcResponseMsg(MqttDeviceAwareSessionContext deviceCtx, Integer requestId, String data, String deviceName, int msgId) { + private void processRpcResponseMsg(MqttDeviceAwareSessionContext deviceCtx, Integer requestId, String data, String deviceName, + int msgId, AtomicInteger remaining, AtomicBoolean ackSent) { TransportProtos.ToDeviceRpcResponseMsg rpcResponseMsg = TransportProtos.ToDeviceRpcResponseMsg.newBuilder() .setRequestId(requestId).setPayload(data).build(); - transportService.process(deviceCtx.getSessionInfo(), rpcResponseMsg, getPubAckCallback(channel, deviceName, msgId, rpcResponseMsg)); + transportService.process(deviceCtx.getSessionInfo(), rpcResponseMsg, + getAggregatePubAckCallback(channel, msgId, deviceName, rpcResponseMsg, remaining, ackSent)); } private void processGetAttributeRequestMessage(MqttPublishMessage mqttMsg, String deviceName, TransportProtos.GetAttributeRequestMsg requestMsg) { int msgId = getMsgId(mqttMsg); - process(deviceName, deviceCtx -> processGetAttributeRequestMessage(deviceCtx, requestMsg, deviceName, msgId), - t -> { - failedToProcessLog(deviceName, ATTRIBUTES_REQUEST, t); - ack(mqttMsg, MqttReasonCodes.PubAck.IMPLEMENTATION_SPECIFIC_ERROR); - }); + AtomicInteger remaining = new AtomicInteger(1); + AtomicBoolean ackSent = new AtomicBoolean(false); + process(deviceName, deviceCtx -> { + processGetAttributeRequestMessage(deviceCtx, requestMsg, deviceName, msgId, remaining, ackSent); + }, + t -> processFailure(msgId, deviceName, ATTRIBUTES_REQUEST, ackSent, MqttReasonCodes.PubAck.IMPLEMENTATION_SPECIFIC_ERROR, t)); } - private void processGetAttributeRequestMessage(T deviceCtx, TransportProtos.GetAttributeRequestMsg requestMsg, String deviceName, int msgId) { - transportService.process(deviceCtx.getSessionInfo(), requestMsg, getPubAckCallback(channel, deviceName, msgId, requestMsg)); + private void processGetAttributeRequestMessage(T deviceCtx, TransportProtos.GetAttributeRequestMsg requestMsg, + String deviceName, int msgId, AtomicInteger remaining, AtomicBoolean ackSent) { + transportService.process(deviceCtx.getSessionInfo(), requestMsg, + getAggregatePubAckCallback(channel, msgId, deviceName, requestMsg, remaining, ackSent)); } private TransportProtos.GetAttributeRequestMsg toGetAttributeRequestMsg(int requestId, boolean clientScope, Set keys) { @@ -718,9 +788,11 @@ public abstract class AbstractGatewaySessionHandler pubAckCallback = getAggregatePubAckCallback(channel, -1, deviceName, postTelemetryMsg, + new AtomicInteger(1), new AtomicBoolean(false)); + transportService.process(sessionInfo, postTelemetryMsg, pubAckCallback); } - public ConcurrentMap getDevices () { - return this.devices; - } + protected TransportServiceCallback getAggregatePubAckCallback( + final ChannelHandlerContext ctx, + final int msgId, + final String deviceName, + final T msg, + final AtomicInteger remaining, + final AtomicBoolean ackSent) { - private TransportServiceCallback getPubAckCallback(final ChannelHandlerContext ctx, final String deviceName, final int msgId, final T msg) { return new TransportServiceCallback() { @Override public void onSuccess(Void dummy) { log.trace("[{}][{}][{}][{}] Published msg: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, msg); - if (msgId > 0) { - ctx.writeAndFlush(MqttTransportHandler.createMqttPubAckMsg(deviceSessionCtx, msgId, MqttReasonCodes.PubAck.SUCCESS.byteValue())); - } else { - log.trace("[{}][{}][{}] Wrong msg id: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, msg); - ctx.writeAndFlush(MqttTransportHandler.createMqttPubAckMsg(deviceSessionCtx, msgId, MqttReasonCodes.PubAck.UNSPECIFIED_ERROR.byteValue())); + if (remaining.decrementAndGet() == 0 && ackSent.compareAndSet(false, true)) { + if (msgId > 0) { + ctx.writeAndFlush(MqttTransportHandler.createMqttPubAckMsg( + deviceSessionCtx, msgId, MqttReasonCodes.PubAck.SUCCESS.byteValue())); + } else { + log.trace("[{}][{}][{}] Wrong msg id: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, msgId); + ctx.writeAndFlush(MqttTransportHandler.createMqttPubAckMsg( + deviceSessionCtx, msgId, MqttReasonCodes.PubAck.UNSPECIFIED_ERROR.byteValue())); + } + } + if (msgId <= 0) { closeDeviceSession(deviceName, MqttReasonCodes.Disconnect.MALFORMED_PACKET); } } @@ -767,11 +850,20 @@ public abstract class AbstractGatewaySessionHandler getDefaultAdaptor(); + case V2_JSON -> context.getJsonMqttAdaptor(); + case V2_PROTO -> context.getProtoMqttAdaptor(); + default -> useJsonPayloadFormatForDefaultDownlinkTopics ? context.getJsonMqttAdaptor() : getDefaultAdaptor(); + }; } private MqttTransportAdaptor getDefaultAdaptor() { @@ -269,7 +246,7 @@ public class DeviceSessionCtx extends MqttDeviceAwareSessionContext { } } - public Collection getMsgQueueSnapshot(){ + public Collection getMsgQueueSnapshot() { return Collections.unmodifiableCollection(msgQueue); } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionContext.java index a48ee50054..24d7888a81 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionContext.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionContext.java @@ -22,9 +22,6 @@ import org.thingsboard.server.common.transport.auth.TransportDeviceInfo; import java.util.concurrent.ConcurrentMap; -/** - * Created by nickAS21 on 26.12.22 - */ @ToString(callSuper = true) public class GatewayDeviceSessionContext extends AbstractGatewayDeviceSessionContext { diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java index 2980b2ad60..487fe1a541 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java @@ -28,9 +28,6 @@ import org.thingsboard.server.gen.transport.TransportProtos; import java.util.Optional; import java.util.UUID; -/** - * Created by nickAS21 on 26.12.22 - */ @Slf4j public class GatewaySessionHandler extends AbstractGatewaySessionHandler { diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java index 9db81a4d8b..24d23d7bdc 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java @@ -25,9 +25,6 @@ import java.util.UUID; import java.util.concurrent.ConcurrentMap; import java.util.stream.Collectors; -/** - * Created by ashvayka on 30.08.18. - */ @ToString(callSuper = true) public abstract class MqttDeviceAwareSessionContext extends DeviceAwareSessionContext { @@ -47,7 +44,7 @@ public abstract class MqttDeviceAwareSessionContext extends DeviceAwareSessionCo .stream() .filter(entry -> entry.getKey().matches(topic)) .map(Map.Entry::getValue) - .collect(Collectors.toList()); + .toList(); if (!qosList.isEmpty()) { return MqttQoS.valueOf(qosList.get(0)); } else { diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttTopicMatcher.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttTopicMatcher.java index 950a1f5bd9..ad3c7b9897 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttTopicMatcher.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttTopicMatcher.java @@ -15,26 +15,25 @@ */ package org.thingsboard.server.transport.mqtt.session; +import lombok.Getter; + import java.util.regex.Pattern; public class MqttTopicMatcher { + @Getter private final String topic; private final Pattern topicRegex; public MqttTopicMatcher(String topic) { - if(topic == null){ + if (topic == null) { throw new NullPointerException("topic"); } this.topic = topic; this.topicRegex = Pattern.compile(topic.replace("+", "[^/]+").replace("#", ".+") + "$"); } - public String getTopic() { - return topic; - } - - public boolean matches(String topic){ + public boolean matches(String topic) { return this.topicRegex.matcher(topic).matches(); } @@ -52,4 +51,5 @@ public class MqttTopicMatcher { public int hashCode() { return topic.hashCode(); } + } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java index cdb562c063..9ba4c3f989 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java @@ -21,6 +21,7 @@ import com.google.common.util.concurrent.MoreExecutors; import com.google.gson.JsonSyntaxException; import io.netty.handler.codec.mqtt.MqttMessage; import io.netty.handler.codec.mqtt.MqttPublishMessage; +import io.netty.handler.codec.mqtt.MqttReasonCodes; import io.netty.handler.codec.mqtt.MqttTopicSubscription; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -48,6 +49,8 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState.ONLINE; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.DBIRTH; @@ -61,9 +64,6 @@ import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetr import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_SPLIT_REGEXP; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_STATE_REGEXP; -/** - * Created by nickAS21 on 12.12.22 - */ @Slf4j @SpecVersion(spec = "sparkplug", version = "3.0.0") public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler { @@ -144,37 +144,49 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler contextListenableFuture, int msgId, List postTelemetryMsgList, String deviceName) { + if (CollectionUtils.isEmpty(postTelemetryMsgList)) { + log.debug("[{}] Device telemetry list is empty for: [{}]", sessionId, gateway.getDeviceId()); + } + + AtomicInteger remaining = new AtomicInteger(postTelemetryMsgList.size()); + AtomicBoolean ackSent = new AtomicBoolean(false); + process(contextListenableFuture, deviceCtx -> { for (TransportProtos.PostTelemetryMsg telemetryMsg : postTelemetryMsgList) { try { - processPostTelemetryMsg(deviceCtx, telemetryMsg, deviceName, msgId); + processPostTelemetryMsg(deviceCtx, telemetryMsg, deviceName, msgId, remaining, ackSent); } catch (Throwable e) { log.warn("[{}][{}] Failed to convert telemetry: {}", gateway.getDeviceId(), deviceName, telemetryMsg, e); - ackOrClose(msgId); + ackOrClose(msgId, ackSent); } } }, - t -> log.debug("[{}] Failed to process device telemetry command: {}", sessionId, deviceName, t)); + t -> processFailure(msgId, deviceName, "Failed to process device telemetry command", ackSent, t)); } private void onDeviceAttributesProto(ListenableFuture contextListenableFuture, int msgId, List attributesMsgList, String deviceName) throws AdaptorException { try { if (CollectionUtils.isEmpty(attributesMsgList)) { - log.debug("[{}] Devices attributes keys list is empty for: [{}]", sessionId, gateway.getDeviceId()); + log.debug("[{}] Device attribute list is empty for: [{}]", sessionId, gateway.getDeviceId()); } + + AtomicInteger remaining = new AtomicInteger(attributesMsgList.size()); + AtomicBoolean ackSent = new AtomicBoolean(false); + process(contextListenableFuture, deviceCtx -> { for (TransportApiProtos.AttributesMsg attributesMsg : attributesMsgList) { TransportProtos.PostAttributeMsg kvListProto = attributesMsg.getMsg(); try { TransportProtos.PostAttributeMsg postAttributeMsg = ProtoConverter.validatePostAttributeMsg(kvListProto); - processPostAttributesMsg(deviceCtx, postAttributeMsg, deviceName, msgId); + processPostAttributesMsg(deviceCtx, postAttributeMsg, deviceName, msgId, remaining, ackSent); } catch (Throwable e) { log.warn("[{}][{}] Failed to process device attributes command: {}", gateway.getDeviceId(), deviceName, kvListProto, e); + ackOrClose(msgId, ackSent); } } }, - t -> log.debug("[{}] Failed to process device attributes command: {}", sessionId, deviceName, t)); + t -> processFailure(msgId, deviceName, "Failed to process device attributes command", ackSent, t)); } catch (RuntimeException e) { throw new AdaptorException(e); } diff --git a/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java b/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java index 0f1a56cb17..79b8181548 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java +++ b/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java @@ -15,12 +15,15 @@ */ package org.thingsboard.common.util; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.function.Consumer; @@ -71,4 +74,16 @@ public class DonAsynchron { return future; } + public static FluentFuture toFluentFuture(CompletableFuture completable) { + SettableFuture future = SettableFuture.create(); + completable.whenComplete((result, exception) -> { + if (exception != null) { + future.setException(exception); + } else { + future.set(result); + } + }); + return FluentFuture.from(future); + } + } diff --git a/common/util/src/main/java/org/thingsboard/common/util/ExpressionFunctionsUtil.java b/common/util/src/main/java/org/thingsboard/common/util/ExpressionUtils.java similarity index 87% rename from common/util/src/main/java/org/thingsboard/common/util/ExpressionFunctionsUtil.java rename to common/util/src/main/java/org/thingsboard/common/util/ExpressionUtils.java index b1753e7a17..96b45123a1 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/ExpressionFunctionsUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/ExpressionUtils.java @@ -15,13 +15,16 @@ */ package org.thingsboard.common.util; +import net.objecthunter.exp4j.Expression; +import net.objecthunter.exp4j.ExpressionBuilder; import net.objecthunter.exp4j.function.Function; import net.objecthunter.exp4j.function.Functions; import java.util.ArrayList; import java.util.List; +import java.util.Set; -public class ExpressionFunctionsUtil { +public class ExpressionUtils { public static final List userDefinedFunctions = new ArrayList<>(); @@ -75,4 +78,13 @@ public class ExpressionFunctionsUtil { userDefinedFunctions.add(Functions.getBuiltinFunction("signum")); } + public static Expression createExpression(String expression, Set variables) { + return new ExpressionBuilder(expression) + .functions(userDefinedFunctions) + .implicitMultiplication(true) + .operator() + .variables(variables) + .build(); + } + } diff --git a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java index cd61c7ac20..e3ae70c9a5 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java @@ -83,6 +83,12 @@ public class JacksonUtil { .addModule(new Jdk8Module()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .build(); + public static final ObjectMapper CANONICAL_JSON_MAPPER = JsonMapper.builder() + .addModule(new Jdk8Module()) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true) + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .serializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + .build(); public static ObjectMapper getObjectMapperWithJavaTimeModule() { return JsonMapper.builder() @@ -207,6 +213,23 @@ public class JacksonUtil { return data; } + public static String toCanonicalString(Object value) { + try { + if (value == null) { + return null; + } + + if (value instanceof JsonNode) { + Object pojo = CANONICAL_JSON_MAPPER.convertValue(value, Object.class); + return CANONICAL_JSON_MAPPER.writeValueAsString(pojo); + } + + return CANONICAL_JSON_MAPPER.writeValueAsString(value); + } catch (Exception e) { + throw new IllegalArgumentException("The given Json object value cannot be transformed to a canonical String: " + value, e); + } + } + public static T treeToValue(JsonNode node, Class clazz) { try { return OBJECT_MAPPER.treeToValue(node, clazz); diff --git a/common/util/src/main/java/org/thingsboard/common/util/KvUtil.java b/common/util/src/main/java/org/thingsboard/common/util/KvUtil.java index a924b0228e..0d9b8494f6 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/KvUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/KvUtil.java @@ -61,6 +61,37 @@ public class KvUtil { } } + public static Long getLongValue(KvEntry entry) { + switch (entry.getDataType()) { + case LONG -> { + return entry.getLongValue().orElse(null); + } + case DOUBLE -> { + return entry.getDoubleValue().map(Double::longValue).orElse(null); + } + case BOOLEAN -> { + return entry.getBooleanValue().map(b -> b ? 1L : 0L).orElse(null); + } + case STRING -> { + try { + return Long.parseLong(entry.getStrValue().orElse("")); + } catch (RuntimeException e) { + return null; + } + } + case JSON -> { + try { + return Long.parseLong(entry.getJsonValue().orElse("")); + } catch (RuntimeException e) { + return null; + } + } + default -> { + return null; + } + } + } + public static Boolean getBoolValue(KvEntry entry) { switch (entry.getDataType()) { case LONG: diff --git a/dao/src/main/java/org/thingsboard/server/dao/Dao.java b/dao/src/main/java/org/thingsboard/server/dao/Dao.java index 72883c55ef..4934059cd5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/Dao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/Dao.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao; import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.edqs.fields.EntityFields; import org.thingsboard.server.common.data.id.TenantId; @@ -32,6 +33,10 @@ public interface Dao { ListenableFuture findByIdAsync(TenantId tenantId, UUID id); + default List findEntityInfosByNamePrefix(TenantId tenantId, String name) { + throw new UnsupportedOperationException(); + } + boolean existsById(TenantId tenantId, UUID id); ListenableFuture existsByIdAsync(TenantId tenantId, UUID id); diff --git a/dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java b/dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java index 6a952fd501..93cc64b2db 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; @@ -22,8 +23,8 @@ import java.util.List; public interface ResourceContainerDao> { - List findByTenantIdAndResourceLink(TenantId tenantId, String link, int limit); + List findByTenantIdAndResource(TenantId tenantId, String reference, int limit); - List findByResourceLink(String link, int limit); + List findByResource(String reference, int limit); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java index b091a29247..89240647ba 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java @@ -29,13 +29,17 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.entity.CachedVersionedEntityService; +import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; +import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.model.sql.AiModelEntity; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.sql.JpaExecutorService; import java.util.Optional; import java.util.Set; +import java.util.UUID; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.service.Validator.validatePageLink; @Service @@ -63,11 +67,23 @@ class AiModelServiceImpl extends CachedVersionedEntityService findAiModelByTenantIdAndId(tenantId, modelId))); } + @Override + public Optional findAiModelByTenantIdAndName(TenantId tenantId, String name) { + return Optional.ofNullable(aiModelDao.findByTenantIdAndName(tenantId.getId(), name)); + } + @Override @Transactional public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId) { - return deleteByTenantIdAndIdInternal(tenantId, modelId); + return deleteByTenantIdAndIdInternal(tenantId, modelId.getId()); } @Override @@ -115,6 +136,12 @@ class AiModelServiceImpl extends CachedVersionedEntityService model); // necessary to cast to HasId } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return findAiModelByTenantIdAndIdAsync(tenantId, new AiModelId(entityId.getId())) + .transform(modelOpt -> modelOpt.map(model -> model), directExecutor()); // necessary to cast to HasId + } + @Override public long countByTenantId(TenantId tenantId) { return aiModelDao.countByTenantId(tenantId); @@ -123,14 +150,21 @@ class AiModelServiceImpl extends CachedVersionedEntityService>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(findAlarmByIdAsync(tenantId, new AlarmId(entityId.getId()))) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.ALARM; diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java index 6bf44e4da1..4ddbabb5b6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.asset; +import com.google.common.util.concurrent.FluentFuture; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.ConstraintViolationException; import org.springframework.beans.factory.annotation.Autowired; @@ -49,6 +50,7 @@ import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.service.Validator.validateId; @Service("AssetProfileDaoService") @@ -323,6 +325,12 @@ public class AssetProfileServiceImpl extends CachedVersionedEntityService>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(assetProfileDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.ASSET_PROFILE; diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java index fed201d403..7d8442fd3a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java @@ -15,7 +15,7 @@ */ package org.thingsboard.server.dao.asset; - +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; @@ -26,6 +26,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictPolicy; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.asset.Asset; @@ -62,6 +64,7 @@ import java.util.List; import java.util.Optional; import java.util.stream.Collectors; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.DaoUtil.toUUIDs; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validateIds; @@ -93,8 +96,8 @@ public class BaseAssetService extends AbstractCachedEntityService keys = new ArrayList<>(2); keys.add(new AssetCacheKey(event.getTenantId(), event.getNewName())); @@ -146,14 +149,24 @@ public class BaseAssetService extends AbstractCachedEntityService saveAsset(asset, true, nameConflictStrategy)); + } + @Override public Asset saveAsset(Asset asset, boolean doValidate) { + return saveEntity(asset, () -> saveAsset(asset, doValidate, NameConflictStrategy.DEFAULT)); + } + + private Asset saveAsset(Asset asset, boolean doValidate, NameConflictStrategy nameConflictStrategy) { log.trace("Executing saveAsset [{}]", asset); - Asset oldAsset = null; + Asset oldAsset = (asset.getId() != null) ? assetDao.findById(asset.getTenantId(), asset.getId().getId()) : null; + if (nameConflictStrategy.policy() == NameConflictPolicy.UNIQUIFY && (oldAsset == null || !oldAsset.getName().equals(asset.getName()))) { + uniquifyEntityName(asset, oldAsset, asset::setName, EntityType.ASSET, nameConflictStrategy); + } if (doValidate) { - oldAsset = assetValidator.validate(asset, Asset::getTenantId); - } else if (asset.getId() != null) { - oldAsset = findAssetById(asset.getTenantId(), asset.getId()); + assetValidator.validate(asset, Asset::getTenantId); } AssetCacheEvictEvent evictEvent = new AssetCacheEvictEvent(asset.getTenantId(), asset.getName(), oldAsset != null ? oldAsset.getName() : null); Asset savedAsset; @@ -519,6 +532,12 @@ public class BaseAssetService extends AbstractCachedEntityService>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(findAssetByIdAsync(tenantId, new AssetId(entityId.getId()))) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public long countByTenantId(TenantId tenantId) { return assetDao.countByTenantId(tenantId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java index c0cb886747..d3ca3ccff3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java @@ -15,15 +15,15 @@ */ package org.thingsboard.server.dao.cf; +import com.google.common.util.concurrent.FluentFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.cf.CalculatedField; -import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; -import org.thingsboard.server.common.data.id.CalculatedFieldLinkId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; @@ -33,11 +33,14 @@ import org.thingsboard.server.dao.entity.AbstractEntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.exception.IncorrectParameterException; -import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.dao.service.validator.CalculatedFieldDataValidator; +import java.util.EnumSet; import java.util.List; import java.util.Optional; +import java.util.Set; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validatePageLink; @@ -51,14 +54,11 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements public static final String INCORRECT_ENTITY_ID = "Incorrect entityId "; private final CalculatedFieldDao calculatedFieldDao; - private final CalculatedFieldLinkDao calculatedFieldLinkDao; - private final DataValidator calculatedFieldDataValidator; - private final DataValidator calculatedFieldLinkDataValidator; + private final CalculatedFieldDataValidator calculatedFieldDataValidator; @Override public CalculatedField save(CalculatedField calculatedField) { - CalculatedField oldCalculatedField = calculatedFieldDataValidator.validate(calculatedField, CalculatedField::getTenantId); - return doSave(calculatedField, oldCalculatedField); + return save(calculatedField, true); } @Override @@ -79,14 +79,19 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements log.trace("Executing save calculated field, [{}]", calculatedField); updateDebugSettings(tenantId, calculatedField, System.currentTimeMillis()); CalculatedField savedCalculatedField = calculatedFieldDao.save(tenantId, calculatedField); - createOrUpdateCalculatedFieldLink(tenantId, savedCalculatedField); - eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(savedCalculatedField.getTenantId()).entityId(savedCalculatedField.getId()) - .entity(savedCalculatedField).oldEntity(oldCalculatedField).created(calculatedField.getId() == null).build()); + eventPublisher.publishEvent(SaveEntityEvent.builder() + .tenantId(savedCalculatedField.getTenantId()) + .entityId(savedCalculatedField.getId()) + .entity(savedCalculatedField) + .oldEntity(oldCalculatedField) + .created(calculatedField.getId() == null) + .build()); return savedCalculatedField; } catch (Exception e) { checkConstraintViolation(e, - "calculated_field_unq_key", "Calculated Field with such name is already in exists!", - "calculated_field_external_id_unq_key", "Calculated Field with such external id already exists!"); + "calculated_field_unq_key", calculatedField.getType() == CalculatedFieldType.ALARM ? + "Alarm rule with such type already exists" : "Calculated field with such name and type already exists", + "calculated_field_external_id_unq_key", "Calculated field with such external id already exists"); throw e; } } @@ -100,10 +105,10 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements } @Override - public CalculatedField findByEntityIdAndName(EntityId entityId, String name) { - log.trace("Executing findByEntityIdAndName [{}], calculatedFieldName[{}]", entityId, name); + public CalculatedField findByEntityIdAndTypeAndName(EntityId entityId, CalculatedFieldType type, String name) { + log.trace("Executing findByEntityIdAndTypeAndName entityId [{}], type [{}], name [{}]", entityId, type, name); validateId(entityId.getId(), id -> INCORRECT_ENTITY_ID + id); - return calculatedFieldDao.findByEntityIdAndName(entityId, name); + return calculatedFieldDao.findByEntityIdAndTypeAndName(entityId, type, name); } @Override @@ -136,11 +141,18 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements } @Override - public PageData findAllCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId, PageLink pageLink) { + public PageData findCalculatedFieldsByEntityId(TenantId tenantId, EntityId entityId, CalculatedFieldType type, PageLink pageLink) { log.trace("Executing findAllByEntityId, entityId [{}], pageLink [{}]", entityId, pageLink); validateId(entityId.getId(), id -> INCORRECT_ENTITY_ID + id); validatePageLink(pageLink); - return calculatedFieldDao.findAllByEntityId(tenantId, entityId, pageLink); + Set types; + if (type == null) { + types = EnumSet.allOf(CalculatedFieldType.class); + types.remove(CalculatedFieldType.ALARM); + } else { + types = Set.of(type); + } + return calculatedFieldDao.findByEntityIdAndTypes(tenantId, entityId, types, pageLink); } @Override @@ -178,48 +190,6 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements return calculatedFields.size(); } - @Override - public CalculatedFieldLink saveCalculatedFieldLink(TenantId tenantId, CalculatedFieldLink calculatedFieldLink) { - calculatedFieldLinkDataValidator.validate(calculatedFieldLink, CalculatedFieldLink::getTenantId); - log.trace("Executing save calculated field link, [{}]", calculatedFieldLink); - return calculatedFieldLinkDao.save(tenantId, calculatedFieldLink); - } - - @Override - public CalculatedFieldLink findCalculatedFieldLinkById(TenantId tenantId, CalculatedFieldLinkId calculatedFieldLinkId) { - log.trace("Executing findCalculatedFieldLinkById, tenantId [{}], calculatedFieldLinkId [{}]", tenantId, calculatedFieldLinkId); - validateId(tenantId, id -> INCORRECT_TENANT_ID + id); - validateId(calculatedFieldLinkId, id -> "Incorrect calculatedFieldLinkId " + id); - return calculatedFieldLinkDao.findById(tenantId, calculatedFieldLinkId.getId()); - } - - @Override - public List findAllCalculatedFieldLinksById(TenantId tenantId, CalculatedFieldId calculatedFieldId) { - log.trace("Executing findAllCalculatedFieldLinksById, calculatedFieldId [{}]", calculatedFieldId); - return calculatedFieldLinkDao.findCalculatedFieldLinksByCalculatedFieldId(tenantId, calculatedFieldId); - } - - @Override - public List findAllCalculatedFieldLinksByEntityId(TenantId tenantId, EntityId entityId) { - log.trace("Executing findAllCalculatedFieldLinksByEntityId, entityId [{}]", entityId); - return calculatedFieldLinkDao.findCalculatedFieldLinksByEntityId(tenantId, entityId); - } - - @Override - public PageData findAllCalculatedFieldLinksByTenantId(TenantId tenantId, PageLink pageLink) { - log.trace("Executing findAllCalculatedFieldLinksByTenantId, tenantId[{}] pageLink [{}]", tenantId, pageLink); - validateId(tenantId, id -> INCORRECT_TENANT_ID + id); - validatePageLink(pageLink); - return calculatedFieldLinkDao.findAllByTenantId(tenantId, pageLink); - } - - @Override - public PageData findAllCalculatedFieldLinks(PageLink pageLink) { - log.trace("Executing findAllCalculatedFieldLinks, pageLink [{}]", pageLink); - validatePageLink(pageLink); - return calculatedFieldLinkDao.findAll(pageLink); - } - @Override public boolean referencedInAnyCalculatedField(TenantId tenantId, EntityId referencedEntityId) { return calculatedFieldDao.findAllByTenantId(tenantId).stream() @@ -235,13 +205,14 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements } @Override - public EntityType getEntityType() { - return EntityType.CALCULATED_FIELD; + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(calculatedFieldDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); } - private void createOrUpdateCalculatedFieldLink(TenantId tenantId, CalculatedField calculatedField) { - List links = calculatedField.getConfiguration().buildCalculatedFieldLinks(tenantId, calculatedField.getEntityId(), calculatedField.getId()); - links.forEach(link -> saveCalculatedFieldLink(tenantId, link)); + @Override + public EntityType getEntityType() { + return EntityType.CALCULATED_FIELD; } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java index d5465cb8a1..9e8ee61d4c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.cf; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; @@ -24,6 +25,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.Dao; import java.util.List; +import java.util.Set; public interface CalculatedFieldDao extends Dao { @@ -35,16 +37,16 @@ public interface CalculatedFieldDao extends Dao { List findAll(); - CalculatedField findByEntityIdAndName(EntityId entityId, String name); + CalculatedField findByEntityIdAndTypeAndName(EntityId entityId, CalculatedFieldType type, String name); PageData findAll(PageLink pageLink); PageData findAllByTenantId(TenantId tenantId, PageLink pageLink); - PageData findAllByEntityId(TenantId tenantId, EntityId entityId, PageLink pageLink); + PageData findByEntityIdAndTypes(TenantId tenantId, EntityId entityId, Set types, PageLink pageLink); List removeAllByEntityId(TenantId tenantId, EntityId entityId); - long countCFByEntityId(TenantId tenantId, EntityId entityId); + long countByEntityIdAndTypeNot(TenantId tenantId, EntityId entityId, CalculatedFieldType type); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldLinkDao.java b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldLinkDao.java deleted file mode 100644 index dd184289ed..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldLinkDao.java +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright © 2016-2025 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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.cf; - -import org.thingsboard.server.common.data.cf.CalculatedFieldLink; -import org.thingsboard.server.common.data.id.CalculatedFieldId; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.dao.Dao; - -import java.util.List; - -public interface CalculatedFieldLinkDao extends Dao { - - List findCalculatedFieldLinksByCalculatedFieldId(TenantId tenantId, CalculatedFieldId calculatedFieldId); - - List findCalculatedFieldLinksByEntityId(TenantId tenantId, EntityId entityId); - - List findCalculatedFieldLinksByTenantId(TenantId tenantId); - - List findAll(); - - PageData findAll(PageLink pageLink); - - PageData findAllByTenantId(TenantId tenantId, PageLink pageLink); - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java index b06902d95e..4187a96acc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.customer; import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -28,6 +29,8 @@ import org.thingsboard.server.cache.customer.CustomerCacheEvictEvent; import org.thingsboard.server.cache.customer.CustomerCacheKey; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictPolicy; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; @@ -55,6 +58,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.service.Validator.validateId; @Service("CustomerDaoService") @@ -139,19 +143,29 @@ public class CustomerServiceImpl extends AbstractCachedEntityService saveCustomer(customer, true, nameConflictStrategy)); } private Customer saveCustomer(Customer customer, boolean doValidate) { + return saveCustomer(customer, doValidate, NameConflictStrategy.DEFAULT); + } + + private Customer saveCustomer(Customer customer, boolean doValidate, NameConflictStrategy nameConflictStrategy) { log.trace("Executing saveCustomer [{}]", customer); - String oldCustomerTitle = null; + Customer oldCustomer = (customer.getId() != null) ? customerDao.findById(customer.getTenantId(), customer.getId().getId()) : null; + if (nameConflictStrategy.policy() == NameConflictPolicy.UNIQUIFY && (oldCustomer == null || !oldCustomer.getTitle().equals(customer.getTitle()))) { + uniquifyEntityName(customer, oldCustomer, customer::setTitle, EntityType.CUSTOMER, nameConflictStrategy); + } if (doValidate) { - Customer oldCustomer = customerValidator.validate(customer, Customer::getTenantId); - if (oldCustomer != null) { - oldCustomerTitle = oldCustomer.getTitle(); - } + customerValidator.validate(customer, Customer::getTenantId); } - var evictEvent = new CustomerCacheEvictEvent(customer.getTenantId(), customer.getTitle(), oldCustomerTitle); + var evictEvent = new CustomerCacheEvictEvent(customer.getTenantId(), customer.getTitle(), oldCustomer != null ? oldCustomer.getTitle() : null); try { Customer savedCustomer = customerDao.saveAndFlush(customer.getTenantId(), customer); if (!savedCustomer.isPublic()) { @@ -161,8 +175,13 @@ public class CustomerServiceImpl extends AbstractCachedEntityService>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(findCustomerByIdAsync(tenantId, new CustomerId(entityId.getId()))) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public long countByTenantId(TenantId tenantId) { return customerDao.countByTenantId(tenantId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardServiceImpl.java index 1b21310237..32e15f12fa 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardServiceImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.dashboard; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.ListenableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -61,6 +62,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.service.Validator.validateId; @Service("DashboardDaoService") @@ -70,6 +72,7 @@ public class DashboardServiceImpl extends AbstractEntityService implements Dashb public static final String INCORRECT_DASHBOARD_ID = "Incorrect dashboardId "; public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; + @Autowired private DashboardDao dashboardDao; @@ -157,6 +160,10 @@ public class DashboardServiceImpl extends AbstractEntityService implements Dashb @Override public Dashboard saveDashboard(Dashboard dashboard, boolean doValidate) { + return saveEntity(dashboard, () -> doSaveDashboard(dashboard, doValidate)); + } + + private Dashboard doSaveDashboard(Dashboard dashboard, boolean doValidate) { log.trace("Executing saveDashboard [{}]", dashboard); if (doValidate) { dashboardValidator.validate(dashboard, DashboardInfo::getTenantId); @@ -424,6 +431,12 @@ public class DashboardServiceImpl extends AbstractEntityService implements Dashb return Optional.ofNullable(findDashboardById(tenantId, new DashboardId(entityId.getId()))); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(findDashboardByIdAsync(tenantId, new DashboardId(entityId.getId()))) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public long countByTenantId(TenantId tenantId) { return dashboardDao.countByTenantId(tenantId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsEvictEvent.java index ca265e78b1..33b410bcfe 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsEvictEvent.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsEvictEvent.java @@ -18,9 +18,9 @@ package org.thingsboard.server.dao.device; import lombok.Data; @Data -class DeviceCredentialsEvictEvent { +public class DeviceCredentialsEvictEvent { - private final String newCedentialsId; + private final String newCredentialsId; private final String oldCredentialsId; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java index 12859129a2..3423a74543 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java @@ -47,6 +47,8 @@ import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException; import org.thingsboard.server.dao.service.validator.DeviceCredentialsDataValidator; +import java.util.Objects; + import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validateString; @@ -61,8 +63,8 @@ public class DeviceCredentialsServiceImpl extends AbstractCachedEntityService JacksonUtil.valueToTree(deviceCredentials.getCredentialsId()); + case X509_CERTIFICATE -> JacksonUtil.valueToTree(deviceCredentials.getCredentialsValue()); + default -> JacksonUtil.fromString(deviceCredentials.getCredentialsValue(), JsonNode.class); + }; } private void formatSimpleMqttCredentials(DeviceCredentials deviceCredentials) { @@ -407,4 +407,11 @@ public class DeviceCredentialsServiceImpl extends AbstractCachedEntityService>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(deviceProfileDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.DEVICE_PROFILE; @@ -432,8 +440,7 @@ public class DeviceProfileServiceImpl extends CachedVersionedEntityService 1) { return EncryptionUtil.certTrimNewLinesForChainInDeviceProfile(certificateValue); } - } catch (CertificateException ignored) { - } + } catch (CertificateException ignored) {} return EncryptionUtil.certTrimNewLines(certificateValue); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java index 6d993f3e3d..8474f4f326 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.device; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; @@ -39,6 +40,8 @@ import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.NameConflictPolicy; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.Tenant; @@ -89,6 +92,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.DaoUtil.toUUIDs; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validateIds; @@ -126,23 +130,21 @@ public class DeviceServiceImpl extends CachedVersionedEntityService INCORRECT_DEVICE_ID + id); - if (TenantId.SYS_TENANT_ID.equals(tenantId)) { - return cache.get(new DeviceCacheKey(deviceId), - () -> deviceDao.findById(tenantId, deviceId.getId())); - } else { - return cache.get(new DeviceCacheKey(tenantId, deviceId), - () -> deviceDao.findDeviceByTenantIdAndId(tenantId, deviceId.getId())); - } + return findDeviceByIdInternal(tenantId, deviceId); } @Override public ListenableFuture findDeviceByIdAsync(TenantId tenantId, DeviceId deviceId) { log.trace("Executing findDeviceByIdAsync [{}]", deviceId); validateId(deviceId, id -> INCORRECT_DEVICE_ID + id); + return executor.submit(() -> findDeviceByIdInternal(tenantId, deviceId)); + } + + private Device findDeviceByIdInternal(TenantId tenantId, DeviceId deviceId) { if (TenantId.SYS_TENANT_ID.equals(tenantId)) { - return deviceDao.findByIdAsync(tenantId, deviceId.getId()); + return cache.get(new DeviceCacheKey(deviceId), () -> deviceDao.findById(tenantId, deviceId.getId())); } else { - return deviceDao.findDeviceByTenantIdAndIdAsync(tenantId, deviceId.getId()); + return cache.get(new DeviceCacheKey(tenantId, deviceId), () -> deviceDao.findDeviceByTenantIdAndId(tenantId, deviceId.getId())); } } @@ -167,6 +169,12 @@ public class DeviceServiceImpl extends CachedVersionedEntityService doSaveDeviceWithoutCredentials(device, doValidate, nameConflictStrategy)); + } + + private Device doSaveDeviceWithoutCredentials(Device device, boolean doValidate, NameConflictStrategy nameConflictStrategy) { log.trace("Executing saveDevice [{}]", device); - Device oldDevice = null; + Device oldDevice = (device.getId() != null) ? deviceDao.findById(device.getTenantId(), device.getId().getId()) : null; + if (nameConflictStrategy.policy() == NameConflictPolicy.UNIQUIFY && (oldDevice == null || !oldDevice.getName().equals(device.getName()))) { + uniquifyEntityName(device, oldDevice, device::setName, EntityType.DEVICE, nameConflictStrategy); + } if (doValidate) { - oldDevice = deviceValidator.validate(device, Device::getTenantId); - } else if (device.getId() != null) { - oldDevice = findDeviceById(device.getTenantId(), device.getId()); + deviceValidator.validate(device, Device::getTenantId); } DeviceCacheEvictEvent deviceCacheEvictEvent = new DeviceCacheEvictEvent(device.getTenantId(), device.getId(), device.getName(), oldDevice != null ? oldDevice.getName() : null); try { @@ -256,8 +279,8 @@ public class DeviceServiceImpl extends CachedVersionedEntityService toEvict = new ArrayList<>(3); toEvict.add(new DeviceCacheKey(event.getTenantId(), event.getNewName())); @@ -729,6 +752,12 @@ public class DeviceServiceImpl extends CachedVersionedEntityService>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(findDeviceByIdAsync(tenantId, new DeviceId(entityId.getId()))) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.DEVICE; diff --git a/dao/src/main/java/org/thingsboard/server/dao/domain/DomainServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/domain/DomainServiceImpl.java index c12d4d915e..e4e569c00a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/domain/DomainServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/domain/DomainServiceImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.domain; +import com.google.common.util.concurrent.FluentFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -39,17 +40,16 @@ import org.thingsboard.server.dao.service.validator.DomainDataValidator; import java.util.Comparator; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; + @Slf4j @Service public class DomainServiceImpl extends AbstractEntityService implements DomainService { - public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; - @Autowired private OAuth2ClientDao oauth2ClientDao; @Autowired @@ -66,8 +66,7 @@ public class DomainServiceImpl extends AbstractEntityService implements DomainSe eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entityId(savedDomain.getId()).entity(savedDomain).build()); return savedDomain; } catch (Exception e) { - checkConstraintViolation(e, - Map.of("domain_name_key", "Domain with such name and scheme already exists!")); + checkConstraintViolation(e, "domain_name_key", "Domain with such name and scheme already exists!"); throw e; } } @@ -142,6 +141,12 @@ public class DomainServiceImpl extends AbstractEntityService implements DomainSe return Optional.ofNullable(findDomainById(tenantId, new DomainId(entityId.getId()))); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(domainDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override @Transactional public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { @@ -163,4 +168,5 @@ public class DomainServiceImpl extends AbstractEntityService implements DomainSe public EntityType getEntityType() { return EntityType.DOMAIN; } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java index 0655d05572..708876c393 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.edge; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; @@ -83,6 +84,7 @@ import java.util.Optional; import java.util.UUID; import java.util.stream.Collectors; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.DaoUtil.toUUIDs; import static org.thingsboard.server.dao.edge.BaseRelatedEdgesService.RELATED_EDGES_CACHE_ITEMS; import static org.thingsboard.server.dao.service.Validator.validateId; @@ -137,8 +139,8 @@ public class EdgeServiceImpl extends AbstractCachedEntityService keys = new ArrayList<>(2); keys.add(new EdgeCacheKey(event.getTenantId(), event.getNewName())); @@ -201,6 +203,10 @@ public class EdgeServiceImpl extends AbstractCachedEntityService doSaveEdge(edge)); + } + + private Edge doSaveEdge(Edge edge) { log.trace("Executing saveEdge [{}]", edge); Edge oldEdge = edgeValidator.validate(edge, Edge::getTenantId); EdgeCacheEvictEvent evictEvent = new EdgeCacheEvictEvent(edge.getTenantId(), edge.getName(), oldEdge != null ? oldEdge.getName() : null); @@ -629,6 +635,12 @@ public class EdgeServiceImpl extends AbstractCachedEntityService>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(findEdgeByIdAsync(tenantId, new EdgeId(entityId.getId()))) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.EDGE; diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java index a34e968acc..5e0a0df5ad 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java @@ -21,16 +21,25 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Lazy; +import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.common.util.DebugModeUtil; +import org.thingsboard.server.common.data.EntityInfo; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.HasDebugSettings; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.HasName; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.debug.DebugSettings; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.dao.Dao; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.edge.EdgeService; @@ -44,7 +53,15 @@ import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentMap; +import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static org.thingsboard.server.common.data.UniquifyStrategy.RANDOM; @Slf4j public abstract class AbstractEntityService { @@ -52,6 +69,8 @@ public abstract class AbstractEntityService { public static final String INCORRECT_EDGE_ID = "Incorrect edgeId "; public static final String INCORRECT_PAGE_LINK = "Incorrect page link "; + private final ConcurrentMap entityCreationLocks = new ConcurrentReferenceHashMap<>(16); + @Autowired protected ApplicationEventPublisher eventPublisher; @@ -83,9 +102,26 @@ public abstract class AbstractEntityService { @Lazy protected TbTenantProfileCache tbTenantProfileCache; + @Autowired + protected EntityDaoRegistry entityDaoRegistry; + @Value("${debug.settings.default_duration:15}") private int defaultDebugDurationMinutes; + protected E saveEntity(E entity, Supplier saveFunction) { + if (entity.getId() == null) { + ReentrantLock lock = entityCreationLocks.computeIfAbsent(entity.getTenantId(), id -> new ReentrantLock()); + lock.lock(); + try { + return saveFunction.get(); + } finally { + lock.unlock(); + } + } else { + return saveFunction.get(); + } + } + protected void createRelation(TenantId tenantId, EntityRelation relation) { log.debug("Creating relation: {}", relation); relationService.saveRelation(tenantId, relation); @@ -155,4 +191,33 @@ public abstract class AbstractEntityService { private long getMaxDebugAllUntil(TenantId tenantId, long now) { return now + TimeUnit.MINUTES.toMillis(DebugModeUtil.getMaxDebugAllDuration(tbTenantProfileCache.get(tenantId).getDefaultProfileConfiguration().getMaxDebugModeDurationMinutes(), defaultDebugDurationMinutes)); } + + protected & HasTenantId & HasName> void uniquifyEntityName(E entity, E oldEntity, Consumer setName, EntityType entityType, NameConflictStrategy strategy) { + Dao dao = entityDaoRegistry.getDao(entityType); + List existingEntities = dao.findEntityInfosByNamePrefix(entity.getTenantId(), entity.getName()); + Set existingNames = existingEntities.stream() + .filter(e -> (oldEntity == null || !e.getId().equals(oldEntity.getId()))) + .map(EntityInfo::getName) + .collect(Collectors.toSet()); + + if (existingNames.contains(entity.getName())) { + String uniqueName = generateUniqueName(entity.getName(), existingNames, strategy); + setName.accept(uniqueName); + } + } + + private String generateUniqueName(String baseName, Set existingNames, NameConflictStrategy strategy) { + String newName; + int index = 1; + String separator = strategy.separator(); + boolean isRandom = strategy.uniquifyStrategy() == RANDOM; + + do { + String suffix = isRandom ? StringUtils.randomAlphanumeric(6) : String.valueOf(index++); + newName = baseName + separator + suffix; + } while (existingNames.contains(newName)); + + return newName; + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java index 2fca546fc9..b512f353f5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.entity; +import com.google.common.util.concurrent.FluentFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; @@ -67,15 +68,13 @@ import java.util.concurrent.ExecutionException; import java.util.function.Function; import java.util.stream.Collectors; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.common.data.id.EntityId.NULL_UUID; import static org.thingsboard.server.common.data.query.EntityFilterType.ENTITY_NAME; import static org.thingsboard.server.common.data.query.EntityFilterType.ENTITY_TYPE; import static org.thingsboard.server.dao.service.Validator.validateEntityDataPageLink; import static org.thingsboard.server.dao.service.Validator.validateId; -/** - * Created by ashvayka on 04.05.17. - */ @Service @Slf4j public class BaseEntityService extends AbstractEntityService implements EntityService { @@ -93,7 +92,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe @Autowired @Lazy - EntityServiceRegistry entityServiceRegistry; + private EntityServiceRegistry entityServiceRegistry; @Autowired private EdqsService edqsService; @@ -198,6 +197,11 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe return fetchAndConvert(tenantId, entityId, this::getCustomerId); } + @Override + public FluentFuture> fetchEntityCustomerIdAsync(TenantId tenantId, EntityId entityId) { + return fetchAndConvertAsync(tenantId, entityId, this::getCustomerId); + } + @Override public Optional fetchNameLabelAndCustomerDetails(TenantId tenantId, EntityId entityId) { log.trace("Executing fetchNameLabelAndCustomerDetails [{}]", entityId); @@ -239,6 +243,12 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe return entityOpt.map(converter); } + private FluentFuture> fetchAndConvertAsync(TenantId tenantId, EntityId entityId, Function, T> converter) { + EntityDaoService entityDaoService = entityServiceRegistry.getServiceByEntityType(entityId.getEntityType()); + return entityDaoService.findEntityAsync(tenantId, entityId) + .transform(entityOpt -> entityOpt.map(converter), directExecutor()); + } + private String getName(HasId entity) { return entity instanceof HasName ? ((HasName) entity).getName() : null; } @@ -329,7 +339,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe } if ((query.getEntityFields() == null || query.getEntityFields().isEmpty()) && - (query.getLatestValues() == null || query.getLatestValues().isEmpty())) { + (query.getLatestValues() == null || query.getLatestValues().isEmpty())) { return false; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/DefaultEntityServiceRegistry.java b/dao/src/main/java/org/thingsboard/server/dao/entity/DefaultEntityServiceRegistry.java index 9c50ad621f..a795bddffa 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/DefaultEntityServiceRegistry.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/DefaultEntityServiceRegistry.java @@ -43,9 +43,6 @@ public class DefaultEntityServiceRegistry implements EntityServiceRegistry { if (EntityType.RULE_CHAIN.equals(entityType)) { entityDaoServicesMap.put(EntityType.RULE_NODE, entityDaoService); } - if (EntityType.CALCULATED_FIELD.equals(entityType)) { - entityDaoServicesMap.put(EntityType.CALCULATED_FIELD_LINK, entityDaoService); - } }); log.debug("Initialized EntityServiceRegistry total [{}] entries", entityDaoServicesMap.size()); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 0e742e20db..617782cb97 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.entityview; import com.google.common.base.Function; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; @@ -29,6 +30,8 @@ import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.NameConflictPolicy; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.edge.Edge; @@ -60,13 +63,11 @@ import java.util.List; import java.util.Optional; import java.util.stream.Collectors; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validatePageLink; import static org.thingsboard.server.dao.service.Validator.validateString; -/** - * Created by Victor Basanets on 8/28/2017. - */ @Service("EntityViewDaoService") @Slf4j public class EntityViewServiceImpl extends CachedVersionedEntityService implements EntityViewService { @@ -110,14 +111,24 @@ public class EntityViewServiceImpl extends CachedVersionedEntityService INCORRECT_ENTITY_VIEW_ID + id); + return findEntityViewByIdInternal(tenantId, entityViewId, putInCache); + } + + @Override + public ListenableFuture findEntityViewByIdAsync(TenantId tenantId, EntityViewId entityViewId) { + log.trace("Executing findEntityViewByIdAsync [{}]", entityViewId); + validateId(entityViewId, id -> INCORRECT_ENTITY_VIEW_ID + id); + return service.submit(() -> findEntityViewByIdInternal(tenantId, entityViewId, true)); + } + + private EntityView findEntityViewByIdInternal(TenantId tenantId, EntityViewId entityViewId, boolean putInCache) { EntityViewCacheValue value = cache.get(EntityViewCacheKey.byId(entityViewId), () -> { EntityView entityView = entityViewDao.findById(tenantId, entityViewId.getId()); return new EntityViewCacheValue(entityView, null); @@ -190,7 +212,6 @@ public class EntityViewServiceImpl extends CachedVersionedEntityService entityViewDao.findEntityViewByTenantIdAndName(tenantId.getId(), name).orElse(null) , EntityViewCacheValue::getEntityView, v -> new EntityViewCacheValue(v, null), true); - } @Override @@ -307,13 +328,6 @@ public class EntityViewServiceImpl extends CachedVersionedEntityService findEntityViewByIdAsync(TenantId tenantId, EntityViewId entityViewId) { - log.trace("Executing findEntityViewByIdAsync [{}]", entityViewId); - validateId(entityViewId, id -> INCORRECT_ENTITY_VIEW_ID + id); - return entityViewDao.findByIdAsync(tenantId, entityViewId.getId()); - } - @Override public ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(TenantId tenantId, EntityId entityId) { log.trace("Executing findEntityViewsByTenantIdAndEntityIdAsync, tenantId [{}], entityId [{}]", tenantId, entityId); @@ -479,6 +493,12 @@ public class EntityViewServiceImpl extends CachedVersionedEntityService>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(findEntityViewByIdAsync(tenantId, new EntityViewId(entityId.getId()))) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.ENTITY_VIEW; diff --git a/dao/src/main/java/org/thingsboard/server/dao/job/DefaultJobService.java b/dao/src/main/java/org/thingsboard/server/dao/job/DefaultJobService.java index 360aa0063b..06f219ca3d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/job/DefaultJobService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/job/DefaultJobService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.job; +import com.google.common.util.concurrent.FluentFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -44,6 +45,7 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.common.data.job.JobStatus.CANCELLED; import static org.thingsboard.server.common.data.job.JobStatus.COMPLETED; import static org.thingsboard.server.common.data.job.JobStatus.FAILED; @@ -87,11 +89,7 @@ public class DefaultJobService extends AbstractEntityService implements JobServi } job.getResult().setCancellationTs(System.currentTimeMillis()); JobStatus prevStatus = job.getStatus(); - if (job.getStatus() == QUEUED) { - job.setStatus(CANCELLED); // setting cancelled status right away, because we don't expect stats for cancelled tasks - } else if (job.getStatus() == PENDING) { - job.setStatus(RUNNING); - } + job.setStatus(CANCELLED); saveJob(tenantId, job, true, prevStatus); } @@ -145,7 +143,7 @@ public class DefaultJobService extends AbstractEntityService implements JobServi } } - if (job.getStatus() == RUNNING) { + if (job.getStatus().isOneOf(RUNNING, CANCELLED)) { if (result.getTotalCount() != null && result.getCompletedCount() >= result.getTotalCount()) { if (result.getCancellationTs() > 0) { job.setStatus(CANCELLED); @@ -193,7 +191,7 @@ public class DefaultJobService extends AbstractEntityService implements JobServi private void checkWaitingJobs(TenantId tenantId, JobType jobType) { Job queuedJob = jobDao.findOldestByTenantIdAndTypeAndStatusForUpdate(tenantId, jobType, QUEUED); - if (queuedJob == null) { + if (queuedJob == null || jobDao.existsByTenantIdAndTypeAndStatusOneOf(tenantId, jobType, PENDING, RUNNING)) { return; } queuedJob.setStatus(PENDING); @@ -245,6 +243,12 @@ public class DefaultJobService extends AbstractEntityService implements JobServi return Optional.ofNullable(findJobById(tenantId, (JobId) entityId)); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(jobDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { jobDao.removeById(tenantId, id.getId()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppBundleServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppBundleServiceImpl.java index d20623ad66..98f4a612e7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppBundleServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppBundleServiceImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.mobile; +import com.google.common.util.concurrent.FluentFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -40,11 +41,11 @@ import org.thingsboard.server.dao.service.DataValidator; import java.util.Comparator; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.service.Validator.checkNotNull; @Slf4j @@ -60,7 +61,6 @@ public class MobileAppBundleServiceImpl extends AbstractEntityService implements @Autowired private DataValidator mobileAppBundleDataValidator; - @Override public MobileAppBundle saveMobileAppBundle(TenantId tenantId, MobileAppBundle mobileAppBundle) { log.trace("Executing saveMobileAppBundle [{}]", mobileAppBundle); @@ -71,8 +71,8 @@ public class MobileAppBundleServiceImpl extends AbstractEntityService implements return savedMobileApp; } catch (Exception e) { checkConstraintViolation(e, - Map.of("mobile_app_bundle_android_app_id_key", "Android mobile app is already configured in another bundle!", - "mobile_app_bundle_ios_app_id_key", "IOS mobile app is already configured in another bundle!")); + "mobile_app_bundle_android_app_id_key", "Android mobile app is already configured in another bundle!", + "mobile_app_bundle_ios_app_id_key", "IOS mobile app is already configured in another bundle!"); throw e; } } @@ -149,6 +149,12 @@ public class MobileAppBundleServiceImpl extends AbstractEntityService implements return Optional.ofNullable(findMobileAppBundleById(tenantId, new MobileAppBundleId(entityId.getId()))); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(mobileAppBundleDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override @Transactional public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { @@ -167,4 +173,5 @@ public class MobileAppBundleServiceImpl extends AbstractEntityService implements .collect(Collectors.toList()); mobileAppBundleInfo.setOauth2ClientInfos(clients); } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppServiceImpl.java index f4e803b28e..b7785496d4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppServiceImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.mobile; +import com.google.common.util.concurrent.FluentFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -35,9 +36,10 @@ import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.Validator; -import java.util.Map; import java.util.Optional; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; + @Slf4j @Service public class MobileAppServiceImpl extends AbstractEntityService implements MobileAppService { @@ -58,8 +60,7 @@ public class MobileAppServiceImpl extends AbstractEntityService implements Mobil eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entity(savedMobileApp).build()); return savedMobileApp; } catch (Exception e) { - checkConstraintViolation(e, - Map.of("mobile_app_pkg_name_platform_unq_key", "Mobile app with such package name and platform already exists!")); + checkConstraintViolation(e, "mobile_app_pkg_name_platform_unq_key", "Mobile app with such package name and platform already exists!"); throw e; } } @@ -88,6 +89,12 @@ public class MobileAppServiceImpl extends AbstractEntityService implements Mobil return Optional.ofNullable(findMobileAppById(tenantId, new MobileAppId(entityId.getId()))); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(mobileAppDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override @Transactional public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { @@ -117,4 +124,5 @@ public class MobileAppServiceImpl extends AbstractEntityService implements Mobil public EntityType getEntityType() { return EntityType.MOBILE_APP; } + } 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 3960c67525..aee8356af4 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 @@ -26,8 +26,7 @@ import java.util.UUID; public class ModelConstants { - private ModelConstants() { - } + private ModelConstants() {} public static final UUID NULL_UUID = Uuids.startOf(0); public static final TenantId SYSTEM_TENANT = TenantId.fromUUID(ModelConstants.NULL_UUID); @@ -731,15 +730,6 @@ public class ModelConstants { public static final String CALCULATED_FIELD_CONFIGURATION = "configuration"; public static final String CALCULATED_FIELD_VERSION = "version"; - /** - * Calculated field links constants. - */ - public static final String CALCULATED_FIELD_LINK_TABLE_NAME = "calculated_field_link"; - public static final String CALCULATED_FIELD_LINK_TENANT_ID_COLUMN = TENANT_ID_COLUMN; - public static final String CALCULATED_FIELD_LINK_ENTITY_TYPE = ENTITY_TYPE_COLUMN; - public static final String CALCULATED_FIELD_LINK_ENTITY_ID = ENTITY_ID_COLUMN; - public static final String CALCULATED_FIELD_LINK_CALCULATED_FIELD_ID = "calculated_field_id"; - /** * Tasks constants. */ diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldLinkEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldLinkEntity.java deleted file mode 100644 index 0f2a6455ec..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldLinkEntity.java +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Copyright © 2016-2025 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import lombok.Data; -import lombok.EqualsAndHashCode; -import org.thingsboard.server.common.data.cf.CalculatedFieldLink; -import org.thingsboard.server.common.data.id.CalculatedFieldId; -import org.thingsboard.server.common.data.id.CalculatedFieldLinkId; -import org.thingsboard.server.common.data.id.EntityIdFactory; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.model.BaseEntity; -import org.thingsboard.server.dao.model.BaseSqlEntity; - -import java.util.UUID; - -import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_LINK_CALCULATED_FIELD_ID; -import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_LINK_ENTITY_ID; -import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_LINK_ENTITY_TYPE; -import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_LINK_TABLE_NAME; -import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_LINK_TENANT_ID_COLUMN; - -@Data -@EqualsAndHashCode(callSuper = true) -@Entity -@Table(name = CALCULATED_FIELD_LINK_TABLE_NAME) -public class CalculatedFieldLinkEntity extends BaseSqlEntity implements BaseEntity { - - @Column(name = CALCULATED_FIELD_LINK_TENANT_ID_COLUMN) - private UUID tenantId; - - @Column(name = CALCULATED_FIELD_LINK_ENTITY_TYPE) - private String entityType; - - @Column(name = CALCULATED_FIELD_LINK_ENTITY_ID) - private UUID entityId; - - @Column(name = CALCULATED_FIELD_LINK_CALCULATED_FIELD_ID) - private UUID calculatedFieldId; - - public CalculatedFieldLinkEntity() { - super(); - } - - public CalculatedFieldLinkEntity(CalculatedFieldLink calculatedFieldLink) { - super(calculatedFieldLink); - this.tenantId = calculatedFieldLink.getTenantId().getId(); - this.entityType = calculatedFieldLink.getEntityId().getEntityType().name(); - this.entityId = calculatedFieldLink.getEntityId().getId(); - this.calculatedFieldId = calculatedFieldLink.getCalculatedFieldId().getId(); - } - - @Override - public CalculatedFieldLink toData() { - CalculatedFieldLink calculatedFieldLink = new CalculatedFieldLink(new CalculatedFieldLinkId(id)); - calculatedFieldLink.setCreatedTime(createdTime); - calculatedFieldLink.setTenantId(TenantId.fromUUID(tenantId)); - calculatedFieldLink.setEntityId(EntityIdFactory.getByTypeAndUuid(entityType, entityId)); - calculatedFieldLink.setCalculatedFieldId(new CalculatedFieldId(calculatedFieldId)); - return calculatedFieldLink; - } - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationRequestService.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationRequestService.java index ffed043fe2..35c7839429 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationRequestService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationRequestService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.notification; +import com.google.common.util.concurrent.FluentFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; @@ -38,6 +39,8 @@ import org.thingsboard.server.dao.service.DataValidator; import java.util.List; import java.util.Optional; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; + @Service @Slf4j @RequiredArgsConstructor @@ -129,13 +132,17 @@ public class DefaultNotificationRequestService implements NotificationRequestSer return Optional.ofNullable(findNotificationRequestById(tenantId, new NotificationRequestId(entityId.getId()))); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(notificationRequestDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.NOTIFICATION_REQUEST; } - private static class NotificationRequestValidator extends DataValidator { - - } + private static class NotificationRequestValidator extends DataValidator {} } diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationRuleService.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationRuleService.java index c349b84f0a..b2f79c9555 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationRuleService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationRuleService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.notification; +import com.google.common.util.concurrent.FluentFuture; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.EntityType; @@ -33,9 +34,10 @@ import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import java.util.List; -import java.util.Map; import java.util.Optional; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; + @Service @RequiredArgsConstructor public class DefaultNotificationRuleService extends AbstractEntityService implements NotificationRuleService, EntityDaoService { @@ -56,9 +58,7 @@ public class DefaultNotificationRuleService extends AbstractEntityService implem .created(notificationRule.getId() == null).build()); return savedRule; } catch (Exception e) { - checkConstraintViolation(e, Map.of( - "uq_notification_rule_name", "Notification rule with such name already exists" - )); + checkConstraintViolation(e, "uq_notification_rule_name", "Notification rule with such name already exists"); throw e; } } @@ -114,6 +114,12 @@ public class DefaultNotificationRuleService extends AbstractEntityService implem return Optional.ofNullable(findNotificationRuleById(tenantId, new NotificationRuleId(entityId.getId()))); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(notificationRuleDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.NOTIFICATION_RULE; diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationService.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationService.java index 99efd18aee..8c5a051771 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.notification; +import com.google.common.util.concurrent.FluentFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -37,6 +38,8 @@ import org.thingsboard.server.dao.sql.query.EntityKeyMapping; import java.util.Optional; import java.util.Set; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; + @Service @Slf4j @RequiredArgsConstructor @@ -95,6 +98,12 @@ public class DefaultNotificationService implements NotificationService, EntityDa return Optional.ofNullable(findNotificationById(tenantId, new NotificationId(entityId.getId()))); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(notificationDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.NOTIFICATION; diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTargetService.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTargetService.java index e873b18c76..637067d51e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTargetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTargetService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.notification; +import com.google.common.util.concurrent.FluentFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -25,17 +26,13 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.NotificationTargetId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.notification.NotificationRequestStatus; import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo; import org.thingsboard.server.common.data.notification.targets.NotificationTarget; import org.thingsboard.server.common.data.notification.targets.NotificationTargetConfig; -import org.thingsboard.server.common.data.notification.targets.platform.CustomerUsersFilter; import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig; -import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter; -import org.thingsboard.server.common.data.notification.targets.platform.UserListFilter; import org.thingsboard.server.common.data.notification.targets.platform.UsersFilter; import org.thingsboard.server.common.data.notification.targets.platform.UsersFilterType; import org.thingsboard.server.common.data.page.PageData; @@ -47,12 +44,10 @@ import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.user.UserService; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.stream.Collectors; -import static org.apache.commons.collections4.CollectionUtils.isNotEmpty; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; @Service @Slf4j @@ -72,9 +67,7 @@ public class DefaultNotificationTargetService extends AbstractEntityService impl .created(notificationTarget.getId() == null).build()); return savedTarget; } catch (Exception e) { - checkConstraintViolation(e, Map.of( - "uq_notification_target_name", "Recipients group with such name already exists" - )); + checkConstraintViolation(e, "uq_notification_target_name", "Recipients group with such name already exists"); throw e; } } @@ -115,49 +108,7 @@ public class DefaultNotificationTargetService extends AbstractEntityService impl @Override public PageData findRecipientsForNotificationTargetConfig(TenantId tenantId, PlatformUsersNotificationTargetConfig targetConfig, PageLink pageLink) { UsersFilter usersFilter = targetConfig.getUsersFilter(); - switch (usersFilter.getType()) { - case USER_LIST: { - List users = ((UserListFilter) usersFilter).getUsersIds().stream() - .limit(pageLink.getPageSize()) - .map(UserId::new).map(userId -> userService.findUserById(tenantId, userId)) - .filter(Objects::nonNull).collect(Collectors.toList()); - return new PageData<>(users, 1, users.size(), false); - } - case CUSTOMER_USERS: { - if (tenantId.equals(TenantId.SYS_TENANT_ID)) { - throw new IllegalArgumentException("Customer users target is not supported for system administrator"); - } - CustomerUsersFilter filter = (CustomerUsersFilter) usersFilter; - return userService.findCustomerUsers(tenantId, new CustomerId(filter.getCustomerId()), pageLink); - } - case TENANT_ADMINISTRATORS: { - TenantAdministratorsFilter filter = (TenantAdministratorsFilter) usersFilter; - if (!tenantId.equals(TenantId.SYS_TENANT_ID)) { - return userService.findTenantAdmins(tenantId, pageLink); - } else { - if (isNotEmpty(filter.getTenantsIds())) { - return userService.findTenantAdminsByTenantsIds(filter.getTenantsIds().stream() - .map(TenantId::fromUUID).collect(Collectors.toList()), pageLink); - } else if (isNotEmpty(filter.getTenantProfilesIds())) { - return userService.findTenantAdminsByTenantProfilesIds(filter.getTenantProfilesIds().stream() - .map(TenantProfileId::new).collect(Collectors.toList()), pageLink); - } else { - return userService.findAllTenantAdmins(pageLink); - } - } - } - case SYSTEM_ADMINISTRATORS: - return userService.findSysAdmins(pageLink); - case ALL_USERS: { - if (!tenantId.equals(TenantId.SYS_TENANT_ID)) { - return userService.findUsersByTenantId(tenantId, pageLink); - } else { - return userService.findAllUsers(pageLink); - } - } - default: - throw new IllegalArgumentException("Recipient type not supported"); - } + return userService.findUsersByFilter(tenantId, usersFilter, pageLink); } @Override @@ -229,6 +180,12 @@ public class DefaultNotificationTargetService extends AbstractEntityService impl return Optional.ofNullable(findNotificationTargetById(tenantId, new NotificationTargetId(entityId.getId()))); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(notificationTargetDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.NOTIFICATION_TARGET; diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTemplateService.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTemplateService.java index 8e98714b85..4218e06881 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTemplateService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTemplateService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.notification; +import com.google.common.util.concurrent.FluentFuture; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.EntityType; @@ -37,6 +38,8 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; + @Service @RequiredArgsConstructor public class DefaultNotificationTemplateService extends AbstractEntityService implements NotificationTemplateService, EntityDaoService { @@ -144,6 +147,12 @@ public class DefaultNotificationTemplateService extends AbstractEntityService im return Optional.ofNullable(findNotificationTemplateById(tenantId, new NotificationTemplateId(entityId.getId()))); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(notificationTemplateDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.NOTIFICATION_TEMPLATE; diff --git a/dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2ClientServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2ClientServiceImpl.java index 861633ec3b..a373d36580 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2ClientServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/oauth2/OAuth2ClientServiceImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.oauth2; +import com.google.common.util.concurrent.FluentFuture; import jakarta.transaction.Transactional; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -41,6 +42,7 @@ import java.util.List; import java.util.Optional; import java.util.stream.Collectors; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; @Slf4j @Service("OAuth2ClientService") @@ -107,7 +109,6 @@ public class OAuth2ClientServiceImpl extends AbstractEntityService implements OA .tenantId(tenantId) .entityId(oAuth2ClientId) .build()); - } @Override @@ -149,6 +150,12 @@ public class OAuth2ClientServiceImpl extends AbstractEntityService implements OA return Optional.ofNullable(findOAuth2ClientById(tenantId, new OAuth2ClientId(entityId.getId()))); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(oauth2ClientDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override @Transactional public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java b/dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java index 343a2485ce..16894fe56c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.ota; import com.google.common.hash.HashFunction; import com.google.common.hash.Hashing; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.ListenableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -47,6 +48,7 @@ import org.thingsboard.server.dao.service.PaginatedRemover; import java.nio.ByteBuffer; import java.util.Optional; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validatePageLink; @@ -254,6 +256,12 @@ public class BaseOtaPackageService extends AbstractCachedEntityService>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(findOtaPackageInfoByIdAsync(tenantId, new OtaPackageId(entityId.getId()))) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.OTA_PACKAGE; diff --git a/dao/src/main/java/org/thingsboard/server/dao/queue/BaseQueueService.java b/dao/src/main/java/org/thingsboard/server/dao/queue/BaseQueueService.java index d36d5f2a15..248326a722 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/queue/BaseQueueService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/queue/BaseQueueService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.queue; +import com.google.common.util.concurrent.FluentFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.ConstraintViolationException; @@ -42,6 +43,9 @@ import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import java.util.List; import java.util.Optional; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; +import static org.thingsboard.server.dao.service.Validator.validateId; + @Service("QueueDaoService") @Slf4j @RequiredArgsConstructor @@ -127,7 +131,7 @@ public class BaseQueueService extends AbstractEntityService implements QueueServ @Override public void deleteQueuesByTenantId(TenantId tenantId) { - Validator.validateId(tenantId, "Incorrect tenant id for delete queues request."); + validateId(tenantId, __ -> "Incorrect tenant id for delete queues request."); tenantQueuesRemover.removeEntities(tenantId, tenantId); } @@ -141,24 +145,30 @@ public class BaseQueueService extends AbstractEntityService implements QueueServ return Optional.ofNullable(findQueueById(tenantId, new QueueId(entityId.getId()))); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(queueDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.QUEUE; } - private PaginatedRemover tenantQueuesRemover = - new PaginatedRemover<>() { + private final PaginatedRemover tenantQueuesRemover = new PaginatedRemover<>() { - @Override - protected PageData findEntities(TenantId tenantId, TenantId id, PageLink pageLink) { - return queueDao.findQueuesByTenantId(id, pageLink); - } + @Override + protected PageData findEntities(TenantId tenantId, TenantId id, PageLink pageLink) { + return queueDao.findQueuesByTenantId(id, pageLink); + } - @Override - protected void removeEntity(TenantId tenantId, Queue entity) { - deleteQueue(tenantId, entity.getId()); - } - }; + @Override + protected void removeEntity(TenantId tenantId, Queue entity) { + deleteQueue(tenantId, entity.getId()); + } + + }; private TenantId getSystemOrIsolatedTenantId(TenantId tenantId) { if (!tenantId.equals(TenantId.SYS_TENANT_ID)) { @@ -167,7 +177,7 @@ public class BaseQueueService extends AbstractEntityService implements QueueServ return tenantId; } } - return TenantId.SYS_TENANT_ID; } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/queue/BaseQueueStatsService.java b/dao/src/main/java/org/thingsboard/server/dao/queue/BaseQueueStatsService.java index 9e4c7136d5..4091daac20 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/queue/BaseQueueStatsService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/queue/BaseQueueStatsService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.queue; +import com.google.common.util.concurrent.FluentFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -35,6 +36,7 @@ import org.thingsboard.server.dao.service.Validator; import java.util.List; import java.util.Optional; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validateIds; @@ -106,6 +108,12 @@ public class BaseQueueStatsService extends AbstractEntityService implements Queu return Optional.ofNullable(findQueueStatsById(tenantId, new QueueStatsId(entityId.getId()))); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(queueStatsDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.QUEUE_STATS; diff --git a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java index 87f7e44a1f..ffb800c7f7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java @@ -32,26 +32,32 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.util.CollectionUtils; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.cache.TbTransactionalCache; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntityRelationInfo; +import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; import org.thingsboard.server.common.data.relation.EntityRelationsQuery; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.relation.RelationsSearchParameters; import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.eventsourcing.RelationActionEvent; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.ConstraintValidator; import org.thingsboard.server.dao.sql.JpaExecutorService; import org.thingsboard.server.dao.sql.relation.JpaRelationQueryExecutorService; +import org.thingsboard.server.dao.usagerecord.ApiLimitService; import java.util.ArrayList; import java.util.Collections; @@ -65,8 +71,10 @@ import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; +import java.util.function.Predicate; import static org.thingsboard.server.dao.service.Validator.validateId; +import static org.thingsboard.server.dao.service.Validator.validatePositiveNumber; /** * Created by ashvayka on 28.04.17. @@ -81,6 +89,8 @@ public class BaseRelationService implements RelationService { private final ApplicationEventPublisher eventPublisher; private final JpaExecutorService executor; private final JpaRelationQueryExecutorService relationsExecutor; + private final ApiLimitService apiLimitService; + protected ScheduledExecutorService timeoutExecutorService; @Value("${sql.relations.query_timeout:20}") @@ -89,13 +99,14 @@ public class BaseRelationService implements RelationService { public BaseRelationService(RelationDao relationDao, @Lazy EntityService entityService, TbTransactionalCache cache, ApplicationEventPublisher eventPublisher, JpaExecutorService executor, - JpaRelationQueryExecutorService relationsExecutor) { + JpaRelationQueryExecutorService relationsExecutor, ApiLimitService apiLimitService) { this.relationDao = relationDao; this.entityService = entityService; this.cache = cache; this.eventPublisher = eventPublisher; this.executor = executor; this.relationsExecutor = relationsExecutor; + this.apiLimitService = apiLimitService; } @PostConstruct @@ -495,6 +506,70 @@ public class BaseRelationService implements RelationService { return relationDao.findRuleNodeToRuleChainRelations(ruleChainType, limit); } + @Override + public ListenableFuture> findByRelationPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery) { + return findFilteredRelationsByPathQueryAsync(tenantId, relationPathQuery, null); + } + + @Override + public ListenableFuture> findFilteredRelationsByPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery, Predicate relationFilter) { + log.trace("Executing findByRelationPathQuery, tenantId [{}], relationPathQuery {}", tenantId, relationPathQuery); + validateId(tenantId, id -> "Invalid tenant id: " + id); + validate(relationPathQuery); + int limit = (int) apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxRelatedEntitiesToReturnPerCfArgument); + validatePositiveNumber(limit, "Max related entities limit for relation path query must be positive!"); + if (relationPathQuery.levels().size() == 1) { + RelationPathLevel relationPathLevel = relationPathQuery.levels().get(0); + var relationsFuture = switch (relationPathLevel.direction()) { + case FROM -> findByFromAndTypeAsync(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON); + case TO -> findByToAndTypeAsync(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON); + }; + return Futures.transform(relationsFuture, entityRelations -> { + if (entityRelations == null || entityRelations.isEmpty()) { + return Collections.emptyList(); + } + List relations = relationFilter != null ? filterRelations(entityRelations, relationFilter) : entityRelations; + return relations.size() > limit ? relations.subList(0, limit) : relations; + }, MoreExecutors.directExecutor()); + } + return executor.submit(() -> { + List entityRelations = relationDao.findByRelationPathQuery(tenantId, relationPathQuery, limit); + return relationFilter != null ? filterRelations(entityRelations, relationFilter) : entityRelations; + }); + } + + private List filterRelations(List entityRelations, Predicate relationFilter) { + return entityRelations.stream() + .filter(relationFilter) + .toList(); + } + + @Override + public List findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery relationPathQuery) { + log.trace("Executing findByRelationPathQuery, tenantId [{}], relationPathQuery {}", tenantId, relationPathQuery); + validateId(tenantId, id -> "Invalid tenant id: " + id); + validate(relationPathQuery); + int limit = (int) apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxRelatedEntitiesToReturnPerCfArgument); + if (relationPathQuery.levels().size() == 1) { + RelationPathLevel relationPathLevel = relationPathQuery.levels().get(0); + var relations = switch (relationPathLevel.direction()) { + case FROM -> findByFromAndType(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON); + case TO -> findByToAndType(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON); + }; + return relations.size() > limit ? relations.subList(0, limit) : relations; + } + return relationDao.findByRelationPathQuery(tenantId, relationPathQuery, limit); + } + + private void validate(EntityRelationPathQuery relationPathQuery) { + validateId((UUIDBased) relationPathQuery.rootEntityId(), id -> "Invalid root entity id: " + id); + List levels = relationPathQuery.levels(); + if (CollectionUtils.isEmpty(levels)) { + throw new DataValidationException("Relation path levels should be specified!"); + } + levels.forEach(RelationPathLevel::validate); + } + protected void validate(EntityRelation relation) { if (relation == null) { throw new DataValidationException("Relation type should be specified!"); diff --git a/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java b/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java index c061faed5e..2ec23a0d74 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java @@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.rule.RuleChainType; @@ -71,4 +72,6 @@ public interface RelationDao { List findRuleNodeToRuleChainRelations(RuleChainType ruleChainType, int limit); + List findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery relationPathQuery, int limit); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java index 3be7f5b91c..25ef83d556 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java @@ -17,11 +17,11 @@ package org.thingsboard.server.dao.resource; import com.fasterxml.jackson.databind.JsonNode; import jakarta.annotation.PostConstruct; -import lombok.Data; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.commons.lang3.tuple.Pair; import org.springframework.stereotype.Service; @@ -50,6 +50,7 @@ import org.thingsboard.server.dao.ImageContainerDao; import org.thingsboard.server.dao.asset.AssetProfileDao; import org.thingsboard.server.dao.dashboard.DashboardInfoDao; import org.thingsboard.server.dao.device.DeviceProfileDao; +import org.thingsboard.server.dao.rule.RuleChainDao; import org.thingsboard.server.dao.service.Validator; import org.thingsboard.server.dao.service.validator.ResourceDataValidator; import org.thingsboard.server.dao.util.ImageUtils; @@ -109,8 +110,8 @@ public class BaseImageService extends BaseResourceService implements ImageServic public BaseImageService(TbResourceDao resourceDao, TbResourceInfoDao resourceInfoDao, ResourceDataValidator resourceValidator, AssetProfileDao assetProfileDao, DeviceProfileDao deviceProfileDao, WidgetsBundleDao widgetsBundleDao, - WidgetTypeDao widgetTypeDao, DashboardInfoDao dashboardInfoDao) { - super(resourceDao, resourceInfoDao, resourceValidator, widgetTypeDao, dashboardInfoDao); + WidgetTypeDao widgetTypeDao, DashboardInfoDao dashboardInfoDao, RuleChainDao ruleChainDao) { + super(resourceDao, resourceInfoDao, resourceValidator, widgetTypeDao, dashboardInfoDao, ruleChainDao); this.assetProfileDao = assetProfileDao; this.deviceProfileDao = deviceProfileDao; this.widgetsBundleDao = widgetsBundleDao; @@ -355,8 +356,8 @@ public class BaseImageService extends BaseResourceService implements ImageServic imageName = imageName + type + " image"; UpdateResult result = convertToImageUrl(entity.getTenantId(), imageName, entity.getImage(), Collections.emptyMap()); - entity.setImage(result.getValue()); - return result.isUpdated(); + entity.setImage(result.value()); + return result.updated(); } @Transactional(noRollbackFor = Exception.class) // we don't want transaction to rollback in case of an image processing failure @@ -372,8 +373,8 @@ public class BaseImageService extends BaseResourceService implements ImageServic Map imagesLinks = getResourcesLinks(widgetTypeDetails.getResources()); UpdateResult result = convertToImageUrl(tenantId, prefix + " image", widgetTypeDetails.getImage(), imagesLinks); - boolean updated = result.isUpdated(); - widgetTypeDetails.setImage(result.getValue()); + boolean updated = result.updated(); + widgetTypeDetails.setImage(result.value()); if (widgetTypeDetails.getDescriptor().isObject()) { JsonNode defaultConfig = widgetTypeDetails.getDefaultConfig(); @@ -396,8 +397,8 @@ public class BaseImageService extends BaseResourceService implements ImageServic Map imagesLinks = getResourcesLinks(dashboard.getResources()); var result = convertToImageUrl(tenantId, prefix + " image", dashboard.getImage(), imagesLinks); - boolean updated = result.isUpdated(); - dashboard.setImage(result.getValue()); + boolean updated = result.updated(); + dashboard.setImage(result.value()); updated |= convertToImageUrlsByMapping(tenantId, DASHBOARD_BASE64_MAPPING, Collections.singletonMap("prefix", prefix), dashboard.getConfiguration(), imagesLinks); updated |= convertToImageUrls(tenantId, prefix, dashboard.getConfiguration(), imagesLinks); @@ -408,10 +409,10 @@ public class BaseImageService extends BaseResourceService implements ImageServic AtomicBoolean updated = new AtomicBoolean(false); JacksonUtil.replaceAllByMapping(configuration, mapping, templateParams, (name, value) -> { UpdateResult result = convertToImageUrl(tenantId, name, value, links); - if (result.isUpdated()) { + if (result.updated()) { updated.set(true); } - return result.getValue(); + return result.value(); }); return updated.get(); } @@ -513,10 +514,10 @@ public class BaseImageService extends BaseResourceService implements ImageServic AtomicBoolean updated = new AtomicBoolean(false); JacksonUtil.replaceAll(root, title, (path, value) -> { UpdateResult result = convertToImageUrl(tenantId, path, value, true, links); - if (result.isUpdated()) { + if (result.updated()) { updated.set(true); } - return result.getValue(); + return result.value(); }); return updated.get(); } @@ -677,16 +678,18 @@ public class BaseImageService extends BaseResourceService implements ImageServic private String getImageLink(String value) { if (value.startsWith(DataConstants.TB_IMAGE_PREFIX + "/api/images")) { - return StringUtils.removeStart(value, DataConstants.TB_IMAGE_PREFIX); + return Strings.CS.removeStart(value, DataConstants.TB_IMAGE_PREFIX); } else { return null; } } - @Data(staticConstructor = "of") - private static class UpdateResult { - private final boolean updated; - private final String value; + private record UpdateResult(boolean updated, String value) { + + static UpdateResult of(boolean updated, String value) { + return new UpdateResult(updated, value); + } + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java index bf941256f5..1eed9a6b59 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.resource; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.TextNode; import com.google.common.hash.Hashing; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.ListenableFuture; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; @@ -35,11 +36,13 @@ import org.thingsboard.server.cache.resourceInfo.ResourceInfoCacheKey; import org.thingsboard.server.cache.resourceInfo.ResourceInfoEvictEvent; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ResourceExportData; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.TbResourceDeleteResult; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.TbResourceInfoFilter; @@ -56,6 +59,7 @@ 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.exception.DataValidationException; +import org.thingsboard.server.dao.rule.RuleChainDao; import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.service.Validator; import org.thingsboard.server.dao.service.validator.ResourceDataValidator; @@ -76,6 +80,7 @@ import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.UnaryOperator; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.common.data.StringUtils.isNotEmpty; import static org.thingsboard.server.dao.device.DeviceServiceImpl.INCORRECT_TENANT_ID; import static org.thingsboard.server.dao.service.Validator.validateId; @@ -92,16 +97,20 @@ public class BaseResourceService extends AbstractCachedEntityService> resourceContainerDaoMap = new HashMap<>(); + protected final RuleChainDao ruleChainDao; + private final Map> resourceLinkContainerDaoMap = new HashMap<>(); + private final Map> generalResourceContainerDaoMap = new HashMap<>(); protected static final int MAX_ENTITIES_TO_FIND = 10; @PostConstruct public void init() { - resourceContainerDaoMap.put(EntityType.WIDGET_TYPE, widgetTypeDao); - resourceContainerDaoMap.put(EntityType.DASHBOARD, dashboardInfoDao); + resourceLinkContainerDaoMap.put(EntityType.WIDGET_TYPE, widgetTypeDao); + resourceLinkContainerDaoMap.put(EntityType.DASHBOARD, dashboardInfoDao); + generalResourceContainerDaoMap.put(EntityType.RULE_CHAIN, ruleChainDao); } - @Autowired @Lazy + @Autowired + @Lazy private ImageService imageService; private static final Map DASHBOARD_RESOURCES_MAPPING = Map.of( @@ -206,6 +215,12 @@ public class BaseResourceService extends AbstractCachedEntityService>> affectedEntities = new HashMap<>(); - - resourceContainerDaoMap.forEach((entityType, resourceContainerDao) -> { - var entities = tenantId.isSysTenantId() ? resourceContainerDao.findByResourceLink(link, MAX_ENTITIES_TO_FIND) : - resourceContainerDao.findByTenantIdAndResourceLink(tenantId, link, MAX_ENTITIES_TO_FIND); - if (!entities.isEmpty()) { - affectedEntities.put(entityType.name(), entities); - } - }); - - if (!affectedEntities.isEmpty()) { - success = false; - result.references(affectedEntities); - } + Map> references = findResourceReferences(tenantId, resource); + if (!references.isEmpty()) { + success = false; + result.references(references); } } if (success) { resourceDao.removeById(tenantId, resourceId.getId()); + publishEvictEvent(new ResourceInfoEvictEvent(tenantId, resourceId)); eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entity(resource).entityId(resourceId).build()); } return result.success(success).build(); } + private Map> findResourceReferences(TenantId tenantId, TbResourceInfo resource) { + Map> references = new HashMap<>(); + + if (resource.getResourceType() == ResourceType.JS_MODULE) { + var ref = resource.getLink(); + findReferences(tenantId, references, ref, resourceLinkContainerDaoMap); + } + + if (resource.getResourceType() == ResourceType.GENERAL) { + var ref = resource.getId().getId().toString(); + findReferences(tenantId, references, ref, generalResourceContainerDaoMap); + } + + return references; + } + + private void findReferences(TenantId tenantId, Map> references, String ref, Map> resourceLinkContainerDaoMap) { + resourceLinkContainerDaoMap.forEach((entityType, dao) -> { + List entities = tenantId.isSysTenantId() + ? dao.findByResource(ref, MAX_ENTITIES_TO_FIND) + : dao.findByTenantIdAndResource(tenantId, ref, MAX_ENTITIES_TO_FIND); + if (!entities.isEmpty()) { + references.put(entityType.name(), entities); + } + }); + } + @Override public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { deleteResource(tenantId, (TbResourceId) id, force); @@ -429,6 +460,12 @@ public class BaseResourceService extends AbstractCachedEntityService>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(findResourceInfoByIdAsync(tenantId, new TbResourceId(entityId.getId()))) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.TB_RESOURCE; @@ -663,6 +700,12 @@ public class BaseResourceService extends AbstractCachedEntityService findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds) { + log.trace("Executing findSystemOrTenantResourcesByIds, tenantId [{}], resourceIds [{}]", tenantId, resourceIds); + return resourceInfoDao.findSystemOrTenantResourcesByIds(tenantId, resourceIds); + } + @Override public String calculateEtag(byte[] data) { return Hashing.sha256().hashBytes(data).toString(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java b/dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java new file mode 100644 index 0000000000..452f86b1a6 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java @@ -0,0 +1,72 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.resource; + +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.google.common.util.concurrent.FluentFuture; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.DonAsynchron; +import org.thingsboard.server.common.data.TbResourceDataInfo; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.sql.JpaExecutorService; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DefaultTbResourceDataCache implements TbResourceDataCache { + + private final ResourceService resourceService; + private final JpaExecutorService executorService; + + @Value("${cache.tbResourceData.maxSize:100000}") + private int cacheMaxSize; + @Value("${cache.tbResourceData.timeToLiveInMinutes:44640}") + private int cacheValueTtl; + private AsyncLoadingCache cache; + + @PostConstruct + private void init() { + cache = Caffeine.newBuilder() + .maximumSize(cacheMaxSize) + .expireAfterAccess(cacheValueTtl, TimeUnit.MINUTES) + .executor(executorService) + .buildAsync((key, executor) -> CompletableFuture.supplyAsync(() -> resourceService.getResourceDataInfo(key.tenantId(), key.resourceId()), executor)); + } + + @Override + public FluentFuture getResourceDataInfoAsync(TenantId tenantId, TbResourceId resourceId) { + log.trace("Retrieving resource data info by id [{}], tenant id [{}] from cache", resourceId, tenantId); + return DonAsynchron.toFluentFuture(cache.get(new ResourceDataKey(tenantId, resourceId))); + } + + @Override + public void evictResourceData(TenantId tenantId, TbResourceId resourceId) { + cache.asMap().remove(new ResourceDataKey(tenantId, resourceId)); + log.trace("Evicted resource data info with id [{}], tenant id [{}]", resourceId, tenantId); + } + + record ResourceDataKey (TenantId tenantId, TbResourceId resourceId) {} + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java index 23b59b5658..1b9f250521 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.resource; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -51,4 +52,5 @@ public interface TbResourceDao extends Dao, TenantEntityWithDataDao, long getResourceSize(TenantId tenantId, TbResourceId resourceId); + TbResourceDataInfo getResourceDataInfo(TenantId tenantId, TbResourceId resourceId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java index 8e97738501..f4fe02843d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.resource; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.TbResourceInfoFilter; +import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -46,4 +47,5 @@ public interface TbResourceInfoDao extends Dao { TbResourceInfo findPublicResourceByKey(ResourceType resourceType, String publicResourceKey); + List findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/rpc/BaseRpcService.java b/dao/src/main/java/org/thingsboard/server/dao/rpc/BaseRpcService.java index 15b9717f47..f06e2e45df 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/rpc/BaseRpcService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/rpc/BaseRpcService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.rpc; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.ListenableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -33,6 +34,7 @@ import org.thingsboard.server.dao.service.PaginatedRemover; import java.util.Optional; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validatePageLink; @@ -40,6 +42,7 @@ import static org.thingsboard.server.dao.service.Validator.validatePageLink; @Slf4j @RequiredArgsConstructor public class BaseRpcService implements RpcService { + public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; public static final String INCORRECT_RPC_ID = "Incorrect rpcId "; @@ -113,21 +116,29 @@ public class BaseRpcService implements RpcService { return Optional.ofNullable(findById(tenantId, new RpcId(entityId.getId()))); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(findRpcByIdAsync(tenantId, new RpcId(entityId.getId()))) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.RPC; } - private PaginatedRemover tenantRpcRemover = - new PaginatedRemover<>() { - @Override - protected PageData findEntities(TenantId tenantId, TenantId id, PageLink pageLink) { - return rpcDao.findAllRpcByTenantId(id, pageLink); - } - - @Override - protected void removeEntity(TenantId tenantId, Rpc entity) { - deleteRpc(tenantId, entity.getId()); - } - }; + private final PaginatedRemover tenantRpcRemover = new PaginatedRemover<>() { + + @Override + protected PageData findEntities(TenantId tenantId, TenantId id, PageLink pageLink) { + return rpcDao.findAllRpcByTenantId(id, pageLink); + } + + @Override + protected void removeEntity(TenantId tenantId, Rpc entity) { + deleteRpc(tenantId, entity.getId()); + } + + }; + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java index 8538bd9492..c10c601884 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.rule; import com.datastax.oss.driver.api.core.uuid.Uuids; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; @@ -28,6 +29,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.BaseDataWithAdditionalInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.edge.Edge; @@ -38,6 +40,7 @@ import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageDataIterable; import org.thingsboard.server.common.data.page.PageLink; @@ -80,6 +83,7 @@ import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.common.data.DataConstants.TENANT; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validateIds; @@ -87,9 +91,6 @@ import static org.thingsboard.server.dao.service.Validator.validatePageLink; import static org.thingsboard.server.dao.service.Validator.validatePositiveNumber; import static org.thingsboard.server.dao.service.Validator.validateString; -/** - * Created by igor on 3/12/18. - */ @Service("RuleChainDaoService") @Slf4j public class BaseRuleChainService extends AbstractEntityService implements RuleChainService { @@ -98,6 +99,7 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; public static final String TB_RULE_CHAIN_INPUT_NODE = "org.thingsboard.rule.engine.flow.TbRuleChainInputNode"; + @Autowired private RuleChainDao ruleChainDao; @@ -125,6 +127,10 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC @Override @Transactional public RuleChain saveRuleChain(RuleChain ruleChain, boolean publishSaveEvent, boolean doValidate) { + return saveEntity(ruleChain, () -> doSaveRuleChain(ruleChain, publishSaveEvent, doValidate)); + } + + private RuleChain doSaveRuleChain(RuleChain ruleChain, boolean publishSaveEvent, boolean doValidate) { log.trace("Executing doSaveRuleChain [{}]", ruleChain); if (doValidate) { ruleChainValidator.validate(ruleChain, RuleChain::getTenantId); @@ -260,7 +266,7 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC firstRuleNodeId = nodes.get(ruleChainMetaData.getFirstNodeIndex()).getId(); } if ((ruleChain.getFirstRuleNodeId() != null && !ruleChain.getFirstRuleNodeId().equals(firstRuleNodeId)) - || (ruleChain.getFirstRuleNodeId() == null && firstRuleNodeId != null)) { + || (ruleChain.getFirstRuleNodeId() == null && firstRuleNodeId != null)) { ruleChain.setFirstRuleNodeId(firstRuleNodeId); } if (ruleChainMetaData.getConnections() != null) { @@ -876,6 +882,17 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC return Optional.ofNullable(hasId); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + ListenableFuture> future; + if (entityId.getEntityType() == EntityType.RULE_NODE) { + future = findRuleNodeByIdAsync(tenantId, new RuleNodeId(entityId.getId())); + } else { + future = findRuleChainByIdAsync(tenantId, new RuleChainId(entityId.getId())); + } + return FluentFuture.from(future).transform(Optional::ofNullable, directExecutor()); + } + @Override public long countByTenantId(TenantId tenantId) { return ruleChainDao.countByTenantId(tenantId); @@ -919,18 +936,11 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC ComponentClusteringMode nodeConfigType = ReflectionUtils.getAnnotationProperty(ruleNode.getType(), "org.thingsboard.rule.engine.api.RuleNode", "clusteringMode"); - switch (nodeConfigType) { - case ENABLED: - singletonMode = false; - break; - case SINGLETON: - singletonMode = true; - break; - case USER_PREFERENCE: - default: - singletonMode = ruleNode.isSingletonMode(); - break; - } + singletonMode = switch (nodeConfigType) { + case ENABLED -> false; + case SINGLETON -> true; + default -> ruleNode.isSingletonMode(); + }; } catch (Exception e) { log.warn("Failed to get clustering mode: {}", ExceptionUtils.getRootCauseMessage(e)); singletonMode = false; diff --git a/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java b/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java index 5b09eec42a..ac716bb4fc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java @@ -21,8 +21,10 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.dao.Dao; import org.thingsboard.server.dao.ExportableEntityDao; +import org.thingsboard.server.dao.ResourceContainerDao; import org.thingsboard.server.dao.TenantEntityDao; import java.util.Collection; @@ -31,7 +33,7 @@ import java.util.UUID; /** * Created by igor on 3/12/18. */ -public interface RuleChainDao extends Dao, TenantEntityDao, ExportableEntityDao { +public interface RuleChainDao extends Dao, TenantEntityDao, ExportableEntityDao, ResourceContainerDao { /** * Find rule chains by tenantId and page link. diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java index 05b782c26c..39faf1bc30 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java @@ -18,9 +18,11 @@ package org.thingsboard.server.dao.service.validator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; -import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.dao.cf.CalculatedFieldDao; @@ -46,15 +48,19 @@ public class CalculatedFieldDataValidator extends DataValidator validateCalculatedFieldConfiguration(calculatedField); validateSchedulingConfiguration(tenantId, calculatedField); validateRelationQuerySourceArguments(tenantId, calculatedField); + validateAggregationConfiguration(tenantId, calculatedField); } @Override protected void validateCreate(TenantId tenantId, CalculatedField calculatedField) { + if (calculatedField.getType() == CalculatedFieldType.ALARM) { + return; + } long maxCFsPerEntity = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxCalculatedFieldsPerEntity); if (maxCFsPerEntity <= 0) { return; } - if (calculatedFieldDao.countCFByEntityId(tenantId, calculatedField.getEntityId()) >= maxCFsPerEntity) { + if (calculatedFieldDao.countByEntityIdAndTypeNot(tenantId, calculatedField.getEntityId(), CalculatedFieldType.ALARM) >= maxCFsPerEntity) { throw new DataValidationException("Calculated fields per entity limit reached!"); } } @@ -87,7 +93,7 @@ public class CalculatedFieldDataValidator extends DataValidator private void validateSchedulingConfiguration(TenantId tenantId, CalculatedField calculatedField) { if (!(calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledUpdateCfg) - || !scheduledUpdateCfg.isScheduledUpdateEnabled()) { + || !scheduledUpdateCfg.isScheduledUpdateEnabled()) { return; } long minAllowedScheduledUpdateInterval = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMinAllowedScheduledUpdateIntervalInSecForCF); @@ -98,10 +104,10 @@ public class CalculatedFieldDataValidator extends DataValidator if (!(calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration argumentsBasedCfg)) { return; } - Map relationQueryBasedArguments = argumentsBasedCfg.getArguments().entrySet() + Map relationQueryBasedArguments = argumentsBasedCfg.getArguments().entrySet() .stream() - .filter(entry -> entry.getValue().hasDynamicSource()) - .collect(Collectors.toMap(Map.Entry::getKey, entry -> (RelationQueryDynamicSourceConfiguration) entry.getValue().getRefDynamicSourceConfiguration())); + .filter(entry -> entry.getValue().hasRelationQuerySource()) + .collect(Collectors.toMap(Map.Entry::getKey, entry -> (RelationPathQueryDynamicSourceConfiguration) entry.getValue().getRefDynamicSourceConfiguration())); if (relationQueryBasedArguments.isEmpty()) { return; } @@ -110,6 +116,17 @@ public class CalculatedFieldDataValidator extends DataValidator wrapAsDataValidation(() -> relationQueryDynamicSourceConfiguration.validateMaxRelationLevel(argumentName, maxRelationLevel))); } + private void validateAggregationConfiguration(TenantId tenantId, CalculatedField calculatedField) { + if (!(calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration aggConfiguration)) { + return; + } + long minAllowedDeduplicationInterval = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMinAllowedDeduplicationIntervalInSecForCF); + if (aggConfiguration.getDeduplicationIntervalInSec() < minAllowedDeduplicationInterval) { + throw new IllegalArgumentException("Deduplication interval is less than configured " + + "minimum allowed interval in tenant profile: " + minAllowedDeduplicationInterval); + } + } + private static void wrapAsDataValidation(Runnable validation) { try { validation.run(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldLinkDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldLinkDataValidator.java deleted file mode 100644 index aaba200c92..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldLinkDataValidator.java +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright © 2016-2025 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.cf.CalculatedFieldLink; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.cf.CalculatedFieldLinkDao; -import org.thingsboard.server.dao.exception.DataValidationException; -import org.thingsboard.server.dao.service.DataValidator; - -@Component -public class CalculatedFieldLinkDataValidator extends DataValidator { - - @Autowired - private CalculatedFieldLinkDao calculatedFieldLinkDao; - - @Override - protected CalculatedFieldLink validateUpdate(TenantId tenantId, CalculatedFieldLink calculatedFieldLink) { - CalculatedFieldLink old = calculatedFieldLinkDao.findById(calculatedFieldLink.getTenantId(), calculatedFieldLink.getId().getId()); - if (old == null) { - throw new DataValidationException("Can't update non existing calculated field link!"); - } - return old; - } - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java index dfd0ee82bc..401b199598 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java @@ -69,6 +69,10 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import static org.thingsboard.server.common.data.device.credentials.lwm2m.Lwm2mServerIdentifier.LWM2M_SERVER_MAX; +import static org.thingsboard.server.common.data.device.credentials.lwm2m.Lwm2mServerIdentifier.PRIMARY_LWM2M_SERVER; +import static org.thingsboard.server.common.data.device.credentials.lwm2m.Lwm2mServerIdentifier.isNotLwm2mServer; + @Slf4j @Component public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator { @@ -337,19 +341,22 @@ public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator 65535) { - throw new DeviceCredentialsValidationException("Bootstrap Server ShortServerId must be in range [0 - 65535]!"); - } - } else { - if (serverConfig.getShortServerId() < 1 || serverConfig.getShortServerId() > 65534) { - throw new DeviceCredentialsValidationException("LwM2M Server ShortServerId must be in range [1 - 65534]!"); + if (serverConfig.isBootstrapServerIs()){ + if (serverConfig.getShortServerId() != null) { + if (serverConfig.getShortServerId() == 0) { + serverConfig.setShortServerId(null); + } else { + throw new DeviceCredentialsValidationException("Bootstrap Server ShortServerId must be null!"); } } } else { - String serverName = serverConfig.isBootstrapServerIs() ? "Bootstrap Server" : "LwM2M Server"; - throw new DeviceCredentialsValidationException(serverName + " ShortServerId must not be null!"); + if (serverConfig.getShortServerId() != null) { + if (isNotLwm2mServer(serverConfig.getShortServerId())) { + throw new DeviceCredentialsValidationException("LwM2M Server ShortServerId must be in range [" + PRIMARY_LWM2M_SERVER.getId() + " - " + LWM2M_SERVER_MAX.getId() + "]!"); + } + } else { + throw new DeviceCredentialsValidationException("LwM2M Server ShortServerId must not be null!"); + } } String server = serverConfig.isBootstrapServerIs() ? "Bootstrap Server" : "LwM2M Server"; diff --git a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java index 604a37f4bb..7440481700 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.settings; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.FluentFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -31,6 +32,8 @@ import org.thingsboard.server.dao.service.Validator; import java.util.Optional; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; + @Service @Slf4j public class AdminSettingsServiceImpl implements AdminSettingsService { @@ -106,6 +109,12 @@ public class AdminSettingsServiceImpl implements AdminSettingsService { return Optional.ofNullable(adminSettingsDao.findById(tenantId, entityId.getId())); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(adminSettingsDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.ADMIN_SETTINGS; 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 e475864684..aa9c29df49 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 @@ -21,6 +21,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.edqs.fields.AssetFields; import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.dao.ExportableEntityRepository; @@ -103,6 +104,10 @@ public interface AssetRepository extends JpaRepository, Expor AssetEntity findByTenantIdAndName(UUID tenantId, String name); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(a.id, 'ASSET', a.name) " + + "FROM AssetEntity a WHERE a.tenantId = :tenantId AND a.name LIKE CONCAT(:prefix, '%')") + List findEntityInfosByNamePrefix(UUID tenantId, String prefix); + @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 " + diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java index 4b55884792..593a672d8a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java @@ -21,6 +21,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ProfileEntityIdInfo; @@ -267,6 +268,12 @@ public class JpaAssetDao extends JpaAbstractDao implements A return nativeAssetRepository.findProfileEntityIdInfosByTenantId(tenantId, DaoUtil.toPageable(pageLink)); } + @Override + public List findEntityInfosByNamePrefix(TenantId tenantId, String name) { + log.debug("Find asset entity infos by name [{}]", name); + return assetRepository.findEntityInfosByNamePrefix(tenantId.getId(), name); + } + @Override public Long countByTenantId(TenantId tenantId) { return assetRepository.countByTenantId(tenantId.getId()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldLinkRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldLinkRepository.java deleted file mode 100644 index 6f6a0775f3..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldLinkRepository.java +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright © 2016-2025 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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.cf; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.thingsboard.server.dao.model.sql.CalculatedFieldLinkEntity; - -import java.util.List; -import java.util.UUID; - -public interface CalculatedFieldLinkRepository extends JpaRepository { - - List findAllByTenantIdAndCalculatedFieldId(UUID tenantId, UUID calculatedFieldId); - - List findAllByTenantIdAndEntityId(UUID tenantId, UUID entityId); - - List findAllByTenantId(UUID tenantId); - - Page findAllByTenantId(UUID tenantId, Pageable pageable); - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java index 7755ef036b..6725387fba 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java @@ -29,7 +29,7 @@ public interface CalculatedFieldRepository extends JpaRepository findCalculatedFieldIdsByTenantIdAndEntityId(UUID tenantId, UUID entityId); @@ -38,14 +38,14 @@ public interface CalculatedFieldRepository extends JpaRepository findAllByTenantId(UUID tenantId, Pageable pageable); @Query("SELECT cf FROM CalculatedFieldEntity cf WHERE cf.tenantId = :tenantId " + - "AND cf.entityId = :entityId " + - "AND (:textSearch IS NULL OR ilike(cf.name, CONCAT('%', :textSearch, '%')) = true)") - Page findAllByTenantIdAndEntityId(UUID tenantId, UUID entityId, String textSearch, Pageable pageable); + "AND cf.entityId = :entityId AND cf.type IN :types " + + "AND (:textSearch IS NULL OR ilike(cf.name, CONCAT('%', :textSearch, '%')) = true)") + Page findByTenantIdAndEntityIdAndTypes(UUID tenantId, UUID entityId, List types, String textSearch, Pageable pageable); List findAllByTenantId(UUID tenantId); List removeAllByTenantIdAndEntityId(UUID tenantId, UUID entityId); - long countByTenantIdAndEntityId(UUID tenantId, UUID entityId); + long countByTenantIdAndEntityIdAndTypeNot(UUID tenantId, UUID entityId, String type); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java index bbce4e2721..f2b565131e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java @@ -25,12 +25,10 @@ import org.springframework.transaction.support.TransactionTemplate; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.cf.CalculatedField; -import org.thingsboard.server.common.data.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.debug.DebugSettings; import org.thingsboard.server.common.data.id.CalculatedFieldId; -import org.thingsboard.server.common.data.id.CalculatedFieldLinkId; import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -49,9 +47,6 @@ public class DefaultNativeCalculatedFieldRepository implements NativeCalculatedF private final String CF_COUNT_QUERY = "SELECT count(id) FROM calculated_field;"; private final String CF_QUERY = "SELECT * FROM calculated_field ORDER BY created_time ASC LIMIT %s OFFSET %s"; - private final String CFL_COUNT_QUERY = "SELECT count(id) FROM calculated_field_link;"; - private final String CFL_QUERY = "SELECT * FROM calculated_field_link ORDER BY created_time ASC LIMIT %s OFFSET %s"; - private final NamedParameterJdbcTemplate jdbcTemplate; private final TransactionTemplate transactionTemplate; @@ -103,37 +98,4 @@ public class DefaultNativeCalculatedFieldRepository implements NativeCalculatedF }); } - @Override - public PageData findCalculatedFieldLinks(Pageable pageable) { - return transactionTemplate.execute(status -> { - long startTs = System.currentTimeMillis(); - int totalElements = jdbcTemplate.queryForObject(CFL_COUNT_QUERY, Collections.emptyMap(), Integer.class); - log.debug("Count query took {} ms", System.currentTimeMillis() - startTs); - startTs = System.currentTimeMillis(); - List> rows = jdbcTemplate.queryForList(String.format(CFL_QUERY, pageable.getPageSize(), pageable.getOffset()), Collections.emptyMap()); - log.debug("Main query took {} ms", System.currentTimeMillis() - startTs); - int totalPages = pageable.getPageSize() > 0 ? (int) Math.ceil((float) totalElements / pageable.getPageSize()) : 1; - boolean hasNext = pageable.getPageSize() > 0 && totalElements > pageable.getOffset() + rows.size(); - var data = rows.stream().map(row -> { - - UUID id = (UUID) row.get("id"); - long createdTime = (long) row.get("created_time"); - UUID tenantId = (UUID) row.get("tenant_id"); - EntityType entityType = EntityType.valueOf((String) row.get("entity_type")); - UUID entityId = (UUID) row.get("entity_id"); - UUID calculatedFieldId = (UUID) row.get("calculated_field_id"); - - CalculatedFieldLink calculatedFieldLink = new CalculatedFieldLink(); - calculatedFieldLink.setId(new CalculatedFieldLinkId(id)); - calculatedFieldLink.setCreatedTime(createdTime); - calculatedFieldLink.setTenantId(new TenantId(tenantId)); - calculatedFieldLink.setEntityId(EntityIdFactory.getByTypeAndUuid(entityType, entityId)); - calculatedFieldLink.setCalculatedFieldId(new CalculatedFieldId(calculatedFieldId)); - - return calculatedFieldLink; - }).collect(Collectors.toList()); - return new PageData<>(data, totalPages, totalElements, hasNext); - }); - } - } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java index 385839dded..3cd9285d78 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java @@ -22,6 +22,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; @@ -34,6 +35,7 @@ import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; import java.util.List; +import java.util.Set; import java.util.UUID; @Slf4j @@ -66,8 +68,8 @@ public class JpaCalculatedFieldDao extends JpaAbstractDao findAllByEntityId(TenantId tenantId, EntityId entityId, PageLink pageLink) { - log.debug("Try to find calculated fields by entityId[{}] and pageLink [{}]", entityId, pageLink); - return DaoUtil.toPageData(calculatedFieldRepository.findAllByTenantIdAndEntityId(tenantId.getId(), entityId.getId(), pageLink.getTextSearch(), DaoUtil.toPageable(pageLink))); + public PageData findByEntityIdAndTypes(TenantId tenantId, EntityId entityId, Set types, PageLink pageLink) { + log.debug("Try to find calculated fields by entityId [{}] and type [{}] and pageLink [{}]", entityId, types, pageLink); + return DaoUtil.toPageData(calculatedFieldRepository.findByTenantIdAndEntityIdAndTypes(tenantId.getId(), entityId.getId(), + types.stream().map(Enum::name).toList(), pageLink.getTextSearch(), DaoUtil.toPageable(pageLink))); } @Override @@ -95,8 +98,8 @@ public class JpaCalculatedFieldDao extends JpaAbstractDao implements CalculatedFieldLinkDao { - - private final CalculatedFieldLinkRepository calculatedFieldLinkRepository; - private final NativeCalculatedFieldRepository nativeCalculatedFieldRepository; - - @Override - public List findCalculatedFieldLinksByCalculatedFieldId(TenantId tenantId, CalculatedFieldId calculatedFieldId) { - return DaoUtil.convertDataList(calculatedFieldLinkRepository.findAllByTenantIdAndCalculatedFieldId(tenantId.getId(), calculatedFieldId.getId())); - } - - @Override - public List findCalculatedFieldLinksByEntityId(TenantId tenantId, EntityId entityId) { - return DaoUtil.convertDataList(calculatedFieldLinkRepository.findAllByTenantIdAndEntityId(tenantId.getId(), entityId.getId())); - } - - @Override - public List findCalculatedFieldLinksByTenantId(TenantId tenantId) { - return DaoUtil.convertDataList(calculatedFieldLinkRepository.findAllByTenantId(tenantId.getId())); - } - - @Override - public List findAll() { - return DaoUtil.convertDataList(calculatedFieldLinkRepository.findAll()); - } - - @Override - public PageData findAll(PageLink pageLink) { - log.debug("Try to find calculated field links by pageLink [{}]", pageLink); - return nativeCalculatedFieldRepository.findCalculatedFieldLinks(DaoUtil.toPageable(pageLink)); - } - - @Override - public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { - log.debug("Try to find calculated field links by tenantId [{}], pageLink [{}]", tenantId, pageLink); - return DaoUtil.toPageData(calculatedFieldLinkRepository.findAllByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); - } - - @Override - protected Class getEntityClass() { - return CalculatedFieldLinkEntity.class; - } - - @Override - protected JpaRepository getRepository() { - return calculatedFieldLinkRepository; - } - - @Override - public EntityType getEntityType() { - return EntityType.CALCULATED_FIELD_LINK; - } - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/NativeCalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/NativeCalculatedFieldRepository.java index f37a5764a0..7cc3507076 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/NativeCalculatedFieldRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/NativeCalculatedFieldRepository.java @@ -17,13 +17,10 @@ package org.thingsboard.server.dao.sql.cf; import org.springframework.data.domain.Pageable; import org.thingsboard.server.common.data.cf.CalculatedField; -import org.thingsboard.server.common.data.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.page.PageData; public interface NativeCalculatedFieldRepository { PageData findCalculatedFields(Pageable pageable); - PageData findCalculatedFieldLinks(Pageable pageable); - } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java index 8ad7311423..9c196a5072 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java @@ -21,6 +21,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.edqs.fields.CustomerFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.CustomerEntity; @@ -41,6 +42,10 @@ public interface CustomerRepository extends JpaRepository, CustomerEntity findByTenantIdAndTitle(UUID tenantId, String title); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(a.id, 'CUSTOMER', a.title) " + + "FROM CustomerEntity a WHERE a.tenantId = :tenantId AND a.title LIKE CONCAT(:prefix, '%')") + List findEntityInfosByNamePrefix(UUID tenantId, String prefix); + @Query(value = "SELECT * FROM customer c WHERE c.tenant_id = :tenantId " + "AND c.is_public IS TRUE ORDER BY c.id ASC LIMIT 1", nativeQuery = true) CustomerEntity findPublicCustomerByTenantId(@Param("tenantId") UUID tenantId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java index 75e7179391..2e1d75a738 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java @@ -20,6 +20,7 @@ import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.edqs.fields.CustomerFields; import org.thingsboard.server.common.data.id.CustomerId; @@ -117,6 +118,11 @@ public class JpaCustomerDao extends JpaAbstractDao imp return customerRepository.findNextBatch(id, Limit.of(batchSize)); } + @Override + public List findEntityInfosByNamePrefix(TenantId tenantId, String name) { + return customerRepository.findEntityInfosByNamePrefix(tenantId.getId(), name); + } + @Override public EntityType getEntityType() { return EntityType.CUSTOMER; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java index 7624ddc738..32ac596562 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java @@ -20,6 +20,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.dao.model.sql.DashboardInfoEntity; import java.util.List; @@ -87,12 +88,15 @@ public interface DashboardInfoRepository extends JpaRepository findByImageLink(@Param("imageLink") String imageLink, @Param("limit") int limit); - @Query(value = "SELECT * FROM dashboard d WHERE d.tenant_id = :tenantId and d.configuration ILIKE CONCAT('%', :link, '%') limit :limit", - nativeQuery = true) - List findDashboardInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, @Param("link") String link, @Param("limit") int limit); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(d.id, 'DASHBOARD', d.title) " + + "FROM DashboardEntity d WHERE d.tenantId = :tenantId AND ilike(cast(d.configuration as string), CONCAT('%', :link, '%')) = true") + List findDashboardInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, + @Param("link") String link, + Pageable pageable); - @Query(value = "SELECT * FROM dashboard d WHERE d.configuration ILIKE CONCAT('%', :link, '%') limit :limit", - nativeQuery = true) - List findDashboardInfosByResourceLink(@Param("link") String link, @Param("limit") int limit); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(d.id, 'DASHBOARD', d.title) " + + "FROM DashboardEntity d WHERE ilike(cast(d.configuration as string), CONCAT('%', :link, '%')) = true") + List findDashboardInfosByResourceLink(@Param("link") String link, + Pageable pageable); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java index bc07139725..0e04c94a46 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java @@ -17,9 +17,11 @@ package org.thingsboard.server.dao.sql.dashboard; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.DashboardInfo; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -135,13 +137,13 @@ public class JpaDashboardInfoDao extends JpaAbstractDao findByTenantIdAndResourceLink(TenantId tenantId, String url, int limit) { - return DaoUtil.convertDataList(dashboardInfoRepository.findDashboardInfosByTenantIdAndResourceLink(tenantId.getId(), url, limit)); + public List findByTenantIdAndResource(TenantId tenantId, String reference, int limit) { + return dashboardInfoRepository.findDashboardInfosByTenantIdAndResourceLink(tenantId.getId(), reference, PageRequest.of(0, limit)); } @Override - public List findByResourceLink(String link, int limit) { - return DaoUtil.convertDataList(dashboardInfoRepository.findDashboardInfosByResourceLink(link, limit)); + public List findByResource(String reference, int limit) { + return dashboardInfoRepository.findDashboardInfosByResourceLink(reference, PageRequest.of(0, limit)); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeAssetRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeAssetRepository.java index ec47da3499..3057b0d30f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeAssetRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeAssetRepository.java @@ -23,9 +23,12 @@ import org.springframework.transaction.support.TransactionTemplate; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; +import java.util.Map; import java.util.UUID; @Repository @@ -40,23 +43,24 @@ public class DefaultNativeAssetRepository extends AbstractNativeRepository imple @Override public PageData findProfileEntityIdInfos(Pageable pageable) { - String PROFILE_ASSET_ID_INFO_QUERY = "SELECT tenant_id as tenantId, asset_profile_id as profileId, id as id FROM asset ORDER BY created_time ASC LIMIT %s OFFSET %s"; - return find(COUNT_QUERY, PROFILE_ASSET_ID_INFO_QUERY, pageable, row -> { - AssetId id = new AssetId((UUID) row.get("id")); - AssetProfileId profileId = new AssetProfileId((UUID) row.get("profileId")); - var tenantIdObj = row.get("tenantId"); - return ProfileEntityIdInfo.create(tenantIdObj != null ? (UUID) tenantIdObj : TenantId.SYS_TENANT_ID.getId(), profileId, id); - }); + String PROFILE_ASSET_ID_INFO_QUERY = "SELECT tenant_id as tenantId, customer_id as customerId, asset_profile_id as profileId, id as id FROM asset ORDER BY created_time ASC LIMIT %s OFFSET %s"; + return find(COUNT_QUERY, PROFILE_ASSET_ID_INFO_QUERY, pageable, DefaultNativeAssetRepository::toInfo); } @Override public PageData findProfileEntityIdInfosByTenantId(UUID tenantId, Pageable pageable) { - String PROFILE_ASSET_ID_INFO_QUERY = String.format("SELECT tenant_id as tenantId, asset_profile_id as profileId, id as id FROM asset WHERE tenant_id = '%s' ORDER BY created_time ASC LIMIT %%s OFFSET %%s", tenantId); - return find(COUNT_QUERY, PROFILE_ASSET_ID_INFO_QUERY, pageable, row -> { - AssetId id = new AssetId((UUID) row.get("id")); - AssetProfileId profileId = new AssetProfileId((UUID) row.get("profileId")); - var tenantIdObj = row.get("tenantId"); - return ProfileEntityIdInfo.create(tenantIdObj != null ? (UUID) tenantIdObj : TenantId.SYS_TENANT_ID.getId(), profileId, id); - }); + String PROFILE_ASSET_ID_INFO_QUERY = String.format("SELECT tenant_id as tenantId, customer_id as customerId, asset_profile_id as profileId, id as id FROM asset WHERE tenant_id = '%s' ORDER BY created_time ASC LIMIT %%s OFFSET %%s", tenantId); + return find(COUNT_QUERY, PROFILE_ASSET_ID_INFO_QUERY, pageable, DefaultNativeAssetRepository::toInfo); } + + private static ProfileEntityIdInfo toInfo(Map row) { + var tenantIdObj = row.get("tenantId"); + UUID tenantId = tenantIdObj != null ? (UUID) tenantIdObj : TenantId.SYS_TENANT_ID.getId(); + AssetId id = new AssetId((UUID) row.get("id")); + CustomerId customerId = new CustomerId((UUID) row.get("customerId")); + EntityId ownerId = !customerId.isNullUid() ? customerId : TenantId.fromUUID(tenantId); + AssetProfileId profileId = new AssetProfileId((UUID) row.get("profileId")); + return ProfileEntityIdInfo.create(tenantId, ownerId, profileId, id); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeDeviceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeDeviceRepository.java index 78ee2795b0..49062f829f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeDeviceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeDeviceRepository.java @@ -22,11 +22,14 @@ import org.springframework.stereotype.Repository; import org.springframework.transaction.support.TransactionTemplate; import org.thingsboard.server.common.data.DeviceIdInfo; import org.thingsboard.server.common.data.ProfileEntityIdInfo; +import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; +import java.util.Map; import java.util.UUID; @Repository @@ -52,24 +55,24 @@ public class DefaultNativeDeviceRepository extends AbstractNativeRepository impl @Override public PageData findProfileEntityIdInfos(Pageable pageable) { - String PROFILE_DEVICE_ID_INFO_QUERY = "SELECT tenant_id as tenantId, device_profile_id as profileId, id as id FROM device ORDER BY created_time ASC LIMIT %s OFFSET %s"; - return find(COUNT_QUERY, PROFILE_DEVICE_ID_INFO_QUERY, pageable, row -> { - DeviceId id = new DeviceId((UUID) row.get("id")); - DeviceProfileId profileId = new DeviceProfileId((UUID) row.get("profileId")); - var tenantIdObj = row.get("tenantId"); - return ProfileEntityIdInfo.create(tenantIdObj != null ? (UUID) tenantIdObj : TenantId.SYS_TENANT_ID.getId(), profileId, id); - }); + String PROFILE_DEVICE_ID_INFO_QUERY = "SELECT tenant_id as tenantId, customer_id as customerId, device_profile_id as profileId, id as id FROM device ORDER BY created_time ASC LIMIT %s OFFSET %s"; + return find(COUNT_QUERY, PROFILE_DEVICE_ID_INFO_QUERY, pageable, DefaultNativeDeviceRepository::toInfo); } @Override public PageData findProfileEntityIdInfosByTenantId(UUID tenantId, Pageable pageable) { - String PROFILE_DEVICE_ID_INFO_QUERY = String.format("SELECT tenant_id as tenantId, device_profile_id as profileId, id as id FROM device WHERE tenant_id = '%s' ORDER BY created_time ASC LIMIT %%s OFFSET %%s", tenantId); - return find(COUNT_QUERY, PROFILE_DEVICE_ID_INFO_QUERY, pageable, row -> { - DeviceId id = new DeviceId((UUID) row.get("id")); - DeviceProfileId profileId = new DeviceProfileId((UUID) row.get("profileId")); - var tenantIdObj = row.get("tenantId"); - return ProfileEntityIdInfo.create(tenantIdObj != null ? (UUID) tenantIdObj : TenantId.SYS_TENANT_ID.getId(), profileId, id); - }); + String PROFILE_DEVICE_ID_INFO_QUERY = String.format("SELECT tenant_id as tenantId, customer_id as customerId, device_profile_id as profileId, id as id FROM device WHERE tenant_id = '%s' ORDER BY created_time ASC LIMIT %%s OFFSET %%s", tenantId); + return find(COUNT_QUERY, PROFILE_DEVICE_ID_INFO_QUERY, pageable, DefaultNativeDeviceRepository::toInfo); + } + + private static ProfileEntityIdInfo toInfo(Map row) { + var tenantIdObj = row.get("tenantId"); + UUID tenantId = tenantIdObj != null ? (UUID) tenantIdObj : TenantId.SYS_TENANT_ID.getId(); + DeviceId id = new DeviceId((UUID) row.get("id")); + CustomerId customerId = new CustomerId((UUID) row.get("customerId")); + EntityId ownerId = !customerId.isNullUid() ? customerId : TenantId.fromUUID(tenantId); + DeviceProfileId profileId = new DeviceProfileId((UUID) row.get("profileId")); + return ProfileEntityIdInfo.create(tenantId, ownerId, profileId, id); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java index f4c2fed9fa..a9ec9d1d36 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java @@ -22,6 +22,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.edqs.fields.DeviceFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.DeviceEntity; @@ -151,6 +152,10 @@ public interface DeviceRepository extends JpaRepository, Exp DeviceEntity findByTenantIdAndName(UUID tenantId, String name); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(a.id, 'DEVICE', a.name) " + + "FROM DeviceEntity a WHERE a.tenantId = :tenantId AND a.name LIKE CONCAT(:prefix, '%')") + List findEntityInfosByNamePrefix(UUID tenantId, String prefix); + List findDevicesByTenantIdAndCustomerIdAndIdIn(UUID tenantId, UUID customerId, List deviceIds); List findDevicesByTenantIdAndIdIn(UUID tenantId, List deviceIds); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java index 9c79637e39..beb3b7c913 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.DeviceIdInfo; import org.thingsboard.server.common.data.DeviceInfo; import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ProfileEntityIdInfo; @@ -114,6 +115,11 @@ public class JpaDeviceDao extends JpaAbstractDao implement DaoUtil.toPageable(pageLink))); } + @Override + public List findEntityInfosByNamePrefix(TenantId tenantId, String name) { + return deviceRepository.findEntityInfosByNamePrefix(tenantId.getId(), name); + } + @Override public ListenableFuture> findDevicesByTenantIdAndIdsAsync(UUID tenantId, List deviceIds) { return service.submit(() -> DaoUtil.convertDataList(deviceRepository.findDevicesByTenantIdAndIdIn(tenantId, deviceIds))); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java index 6094e9b171..9d51d024b8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java @@ -21,6 +21,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.edqs.fields.EntityViewFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.EntityViewEntity; @@ -118,6 +119,10 @@ public interface EntityViewRepository extends JpaRepository findEntityInfosByNamePrefix(UUID tenantId, String prefix); + List findAllByTenantIdAndEntityId(UUID tenantId, UUID entityId); boolean existsByTenantIdAndEntityId(UUID tenantId, UUID entityId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java index 44d8a09ff4..27400961ef 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java @@ -21,6 +21,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; @@ -230,6 +231,11 @@ public class JpaEntityViewDao extends JpaAbstractDao findEntityInfosByNamePrefix(TenantId tenantId, String name) { + return entityViewRepository.findEntityInfosByNamePrefix(tenantId.getId(), name); + } + @Override public EntityType getEntityType() { return EntityType.ENTITY_VIEW; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmDataAdapter.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmDataAdapter.java index b250fc7337..f3e2ba83a6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmDataAdapter.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmDataAdapter.java @@ -121,6 +121,7 @@ public class AlarmDataAdapter { AlarmData alarmData = new AlarmData(alarm, entityId); alarmData.setOriginatorName(originatorName); alarmData.setOriginatorLabel(originatorLabel); + alarmData.setOriginatorDisplayName(StringUtils.isBlank(originatorLabel) ? originatorName : originatorLabel); if (alarm.getAssigneeId() != null) { alarmData.setAssignee(new AlarmAssignee(alarm.getAssigneeId(), assigneeFirstName, assigneeLastName, assigneeEmail)); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java index 9755201fe9..34bc719639 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java @@ -65,6 +65,7 @@ public class EntityKeyMapping { public static final String NAME = "name"; public static final String TYPE = "type"; public static final String LABEL = "label"; + public static final String DISPLAY_NAME = "displayName"; public static final String FIRST_NAME = "firstName"; public static final String LAST_NAME = "lastName"; public static final String EMAIL = "email"; @@ -83,6 +84,8 @@ public class EntityKeyMapping { public static final String SERVICE_ID = "serviceId"; public static final String OWNER_NAME = "ownerName"; public static final String OWNER_TYPE = "ownerType"; + public static final String LABELED_ENTITY_DISPLAY_NAME_SELECT_QUERY = "COALESCE(NULLIF(TRIM(e." + LABEL + "), ''), e." + NAME + ")"; + public static final String USER_DISPLAY_NAME_SELECT_QUERY = "COALESCE(NULLIF(TRIM(CONCAT_WS(' ', e.first_name, e.last_name)), ''), e.email)"; public static final String OWNER_NAME_SELECT_QUERY = "case when e.customer_id = '" + NULL_UUID + "' " + "then (select title from tenant where id = e.tenant_id) " + "else (select title from customer where id = e.customer_id) end"; @@ -94,6 +97,16 @@ public class EntityKeyMapping { OWNER_NAME, OWNER_NAME_SELECT_QUERY, OWNER_TYPE, OWNER_TYPE_SELECT_QUERY ); + public static final Map labeledPropertiesFunctions = Map.of( + OWNER_NAME, OWNER_NAME_SELECT_QUERY, + OWNER_TYPE, OWNER_TYPE_SELECT_QUERY, + DISPLAY_NAME, LABELED_ENTITY_DISPLAY_NAME_SELECT_QUERY + ); + public static final Map userPropertiesFunctions = Map.of( + OWNER_NAME, OWNER_NAME_SELECT_QUERY, + OWNER_TYPE, OWNER_TYPE_SELECT_QUERY, + DISPLAY_NAME, USER_DISPLAY_NAME_SELECT_QUERY + ); public static final Map queueStatsPropertiesFunctions = Map.of(NAME, QUEUE_STATS_NAME_QUERY); public static final List typedEntityFields = Arrays.asList(CREATED_TIME, ENTITY_TYPE, NAME, TYPE, ADDITIONAL_INFO); @@ -153,20 +166,24 @@ public class EntityKeyMapping { Map contactBasedAliases = new HashMap<>(); contactBasedAliases.put(NAME, TITLE); contactBasedAliases.put(LABEL, TITLE); + contactBasedAliases.put(DISPLAY_NAME, TITLE); aliases.put(EntityType.TENANT, contactBasedAliases); aliases.put(EntityType.CUSTOMER, contactBasedAliases); aliases.put(EntityType.DASHBOARD, contactBasedAliases); + Map deviceAndAssetAliases = new HashMap<>(); + deviceAndAssetAliases.put(TITLE, NAME); + aliases.put(EntityType.DEVICE, deviceAndAssetAliases); + aliases.put(EntityType.ASSET, deviceAndAssetAliases); Map commonEntityAliases = new HashMap<>(); commonEntityAliases.put(TITLE, NAME); - aliases.put(EntityType.DEVICE, commonEntityAliases); - aliases.put(EntityType.ASSET, commonEntityAliases); + commonEntityAliases.put(DISPLAY_NAME, NAME); aliases.put(EntityType.ENTITY_VIEW, commonEntityAliases); aliases.put(EntityType.WIDGETS_BUNDLE, commonEntityAliases); - propertiesFunctions.put(EntityType.DEVICE, ownerPropertiesFunctions); - propertiesFunctions.put(EntityType.ASSET, ownerPropertiesFunctions); + propertiesFunctions.put(EntityType.DEVICE, labeledPropertiesFunctions); + propertiesFunctions.put(EntityType.ASSET, labeledPropertiesFunctions); propertiesFunctions.put(EntityType.ENTITY_VIEW, ownerPropertiesFunctions); - propertiesFunctions.put(EntityType.USER, ownerPropertiesFunctions); + propertiesFunctions.put(EntityType.USER, userPropertiesFunctions); propertiesFunctions.put(EntityType.DASHBOARD, ownerPropertiesFunctions); propertiesFunctions.put(EntityType.QUEUE_STATS, queueStatsPropertiesFunctions); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java index 7417418f54..3ab382d2a4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java @@ -25,6 +25,9 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.dao.DaoUtil; @@ -43,6 +46,7 @@ import java.util.stream.Collectors; import static org.thingsboard.server.dao.model.ModelConstants.RELATION_FROM_ID_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.RELATION_FROM_TYPE_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TABLE_NAME; import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TO_ID_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TO_TYPE_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TYPE_GROUP_PROPERTY; @@ -293,4 +297,107 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple public List findRuleNodeToRuleChainRelations(RuleChainType ruleChainType, int limit) { return DaoUtil.convertDataList(relationRepository.findRuleNodeToRuleChainRelations(ruleChainType, PageRequest.of(0, limit))); } + + @Override + public List findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery query, int limit) { + List levels = query.levels(); + if (levels == null || levels.isEmpty()) { + return List.of(); + } + if (limit <= 0) { + return List.of(); + } + String sql = buildRelationPathSql(query); + Object[] params = buildRelationPathParams(query, limit); + + log.trace("[{}] relation path query: {}", tenantId, sql); + + return jdbcTemplate.queryForList(sql, params).stream() + .map(row -> { + var entityRelation = new EntityRelation(); + var fromId = (UUID) row.get(RELATION_FROM_ID_PROPERTY); + var fromType = (String) row.get(RELATION_FROM_TYPE_PROPERTY); + var toId = (UUID) row.get(RELATION_TO_ID_PROPERTY); + var toType = (String) row.get(RELATION_TO_TYPE_PROPERTY); + var grp = (String) row.get(RELATION_TYPE_GROUP_PROPERTY); + var type = (String) row.get(RELATION_TYPE_PROPERTY); + var version = (Long) row.get(VERSION_COLUMN); + + entityRelation.setFrom(EntityIdFactory.getByTypeAndUuid(fromType, fromId)); + entityRelation.setTo(EntityIdFactory.getByTypeAndUuid(toType, toId)); + entityRelation.setType(type); + entityRelation.setTypeGroup(RelationTypeGroup.valueOf(grp)); + entityRelation.setVersion(version); + return entityRelation; + }) + .collect(Collectors.toList()); + } + + private Object[] buildRelationPathParams(EntityRelationPathQuery query, int limit) { + final List params = new ArrayList<>(); + // seed + params.add(query.rootEntityId().getId()); + params.add(query.rootEntityId().getEntityType().name()); + + // levels + for (var lvl : query.levels()) { + params.add(lvl.relationType()); + } + + // limit + params.add(limit); + + return params.toArray(); + } + + private static String buildRelationPathSql(EntityRelationPathQuery query) { + List levels = query.levels(); + StringBuilder sb = new StringBuilder(); + + sb.append("WITH seed AS (\n") + .append(" SELECT ?::uuid AS id, ?::varchar AS type\n") + .append(")"); + + String prev = "seed"; + for (int i = 0; i < levels.size() - 1; i++) { + RelationPathLevel lvl = levels.get(i); + boolean down = lvl.direction() == EntitySearchDirection.FROM; + + String cur = "lvl" + (i + 1); + String joinCond = down + ? "r.from_id = p.id AND r.from_type = p.type" + : "r.to_id = p.id AND r.to_type = p.type"; + String selectNext = down + ? "r.to_id AS id, r.to_type AS type" + : "r.from_id AS id, r.from_type AS type"; + + sb.append(",\n").append(cur).append(" AS (\n") + .append(" SELECT ").append(selectNext).append("\n") + .append(" FROM ").append(RELATION_TABLE_NAME).append(" r\n") + .append(" JOIN ").append(prev).append(" p ON ").append(joinCond).append("\n") + .append(" WHERE r.relation_type_group = '").append(RelationTypeGroup.COMMON).append("'\n") + .append(" AND r.relation_type = ?\n") + .append(")"); + prev = cur; + } + + RelationPathLevel last = levels.get(levels.size() - 1); + boolean lastDown = last.direction() == EntitySearchDirection.FROM; + String prevForLast = (levels.size() == 1) ? "seed" : prev; + String lastJoin = lastDown + ? "r.from_id = p.id AND r.from_type = p.type" + : "r.to_id = p.id AND r.to_type = p.type"; + + sb.append("\n") + .append("SELECT r.from_id, r.from_type, r.to_id, r.to_type,\n") + .append(" r.relation_type_group, r.relation_type, r.version\n") + .append("FROM ").append(RELATION_TABLE_NAME).append(" r\n") + .append("JOIN ").append(prevForLast).append(" p ON ").append(lastJoin).append("\n") + .append("WHERE r.relation_type_group = '").append(RelationTypeGroup.COMMON).append("'\n") + .append(" AND r.relation_type = ?\n") + .append("LIMIT ?"); + + return sb.toString(); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java index b4e8a21372..4b879f9d95 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.sql.relation; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @@ -96,4 +95,5 @@ public interface RelationRepository @Param("toId") UUID toId, @Param("toType") String toType, @Param("batchSize") int batchSize); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java index 6cce9d76c2..48e41c8553 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -115,6 +116,11 @@ public class JpaTbResourceDao extends JpaAbstractDao findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds) { + return DaoUtil.convertDataList(resourceInfoRepository.findSystemOrTenantResourcesByIdIn(tenantId.getId(), TenantId.NULL_UUID, toUUIDs(resourceIds))); + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java index 6eea20a287..97b1e56527 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java @@ -79,4 +79,10 @@ public interface TbResourceInfoRepository extends JpaRepository findSystemOrTenantResourcesByIdIn(@Param("tenantId") UUID tenantId, + @Param("systemTenantId") UUID systemTenantId, + @Param("resourceIds") List resourceIds); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java index 1c642d2069..4aa699174f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java @@ -20,6 +20,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.TbResourceEntity; @@ -101,4 +102,6 @@ public interface TbResourceRepository extends JpaRepository findIdsByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); + @Query("SELECT new org.thingsboard.server.common.data.TbResourceDataInfo(r.data, r.descriptor) FROM TbResourceEntity r WHERE r.id = :id") + TbResourceDataInfo getDataInfoById(UUID id); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java index 77044d41dc..4a6427a7e5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java @@ -18,8 +18,10 @@ package org.thingsboard.server.dao.sql.rule; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Limit; +import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.edqs.fields.RuleChainFields; import org.thingsboard.server.common.data.id.RuleChainId; @@ -141,6 +143,16 @@ public class JpaRuleChainDao extends JpaAbstractDao return findRuleChainsByTenantId(tenantId.getId(), pageLink); } + @Override + public List findByTenantIdAndResource(TenantId tenantId, String reference, int limit) { + return ruleChainRepository.findRuleChainsByTenantIdAndResource(tenantId.getId(), reference, PageRequest.of(0, limit)); + } + + @Override + public List findByResource(String reference, int limit) { + return ruleChainRepository.findRuleChainsByResource(reference, PageRequest.of(0, limit)); + } + @Override public List findNextBatch(UUID id, int batchSize) { return ruleChainRepository.findNextBatch(id, Limit.of(batchSize)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java index cfa06caf14..4bf648cbbd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java @@ -17,10 +17,12 @@ package org.thingsboard.server.dao.sql.rule; import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.edqs.fields.RuleChainFields; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.dao.ExportableEntityRepository; @@ -72,6 +74,19 @@ public interface RuleChainRepository extends JpaRepository findRuleChainsByTenantIdAndResource(@Param("tenantId") UUID tenantId, + @Param("resourceId") String resourceId, + PageRequest of); + + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(rc.id, 'RULE_CHAIN', rc.name) " + + "FROM RuleChainEntity rc WHERE EXISTS " + + "(SELECT 1 FROM RuleNodeEntity rn WHERE rn.ruleChainId = rc.id AND cast(rn.configuration as string) LIKE CONCAT('%', :resourceId, '%'))") + List findRuleChainsByResource(@Param("resourceId") String resourceId, + Pageable pageable); + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.RuleChainFields(r.id, r.createdTime, r.tenantId," + "r.name, r.version, r.additionalInfo) FROM RuleChainEntity r WHERE r.id > :id ORDER BY r.id") List findNextBatch(@Param("id") UUID id, Limit limit); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java index 35d15bab51..753955089c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserDao.java @@ -136,6 +136,11 @@ public class JpaUserDao extends JpaAbstractDao implements User DaoUtil.toPageable(pageLink))); } + @Override + public int countTenantAdmins(UUID tenantId) { + return userRepository.countByTenantIdAndAuthority(tenantId, Authority.TENANT_ADMIN); + } + @Override public Long countByTenantId(TenantId tenantId) { return userRepository.countByTenantId(tenantId.getId()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserRepository.java index 0a30a859c6..2254377af3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserRepository.java @@ -78,4 +78,6 @@ public interface UserRepository extends JpaRepository { "u.customerId, u.version, u.firstName, u.lastName, u.email, u.phone, u.additionalInfo) " + "FROM UserEntity u WHERE u.id > :id ORDER BY u.id") List findNextBatch(@Param("id") UUID id, Limit limit); + + int countByTenantIdAndAuthority(UUID tenantId, Authority authority); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java index c728f5d006..18fb544dcb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java @@ -17,8 +17,10 @@ package org.thingsboard.server.dao.sql.widget; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Limit; +import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.edqs.fields.WidgetTypeFields; import org.thingsboard.server.common.data.id.TenantId; @@ -269,13 +271,13 @@ public class JpaWidgetTypeDao extends JpaAbstractDao findByTenantIdAndResourceLink(TenantId tenantId, String link, int limit) { - return DaoUtil.convertDataList(widgetTypeInfoRepository.findWidgetTypeInfosByTenantIdAndResourceLink(tenantId.getId(), link, limit)); + public List findByTenantIdAndResource(TenantId tenantId, String reference, int limit) { + return widgetTypeInfoRepository.findWidgetTypeInfosByTenantIdAndResourceLink(tenantId.getId(), reference, PageRequest.of(0, limit)); } @Override - public List findByResourceLink(String link, int limit) { - return DaoUtil.convertDataList(widgetTypeInfoRepository.findWidgetTypeInfosByResourceLink(link, limit)); + public List findByResource(String reference, int limit) { + return widgetTypeInfoRepository.findWidgetTypeInfosByResourceLink(reference, PageRequest.of(0, limit)); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java index dc79280bcf..b97b42a6b9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java @@ -20,6 +20,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.dao.model.sql.WidgetTypeInfoEntity; import java.util.List; @@ -214,10 +215,14 @@ public interface WidgetTypeInfoRepository extends JpaRepository findByImageUrl(@Param("imageLink") String imageLink, @Param("limit") int limit); - @Query(value = "SELECT * FROM widget_type_info_view w WHERE w.tenant_id = :tenantId AND w.descriptor ILIKE CONCAT('%', :link, '%') LIMIT :limit ", nativeQuery = true) - List findWidgetTypeInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, @Param("link") String link, @Param("limit") int limit); - - @Query(value = "SELECT * FROM widget_type_info_view w WHERE w.descriptor ILIKE CONCAT('%', :link, '%') LIMIT :limit ", nativeQuery = true) - List findWidgetTypeInfosByResourceLink(@Param("link") String link, @Param("limit") int limit); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(w.id, 'WIDGET_TYPE', w.name) " + + "FROM WidgetTypeEntity w WHERE w.tenantId = :tenantId AND ilike(cast(w.descriptor as string), CONCAT('%', :link, '%')) = true") + List findWidgetTypeInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, + @Param("link") String link, + Pageable pageable); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(w.id, 'WIDGET_TYPE', w.name) " + + "FROM WidgetTypeEntity w WHERE ilike(cast(w.descriptor as string), CONCAT('%', :link, '%')) = true") + List findWidgetTypeInfosByResourceLink(@Param("link") String link, + Pageable pageable); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileServiceImpl.java index e165d6cfe6..b9172306bb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileServiceImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.tenant; +import com.google.common.util.concurrent.FluentFuture; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.ConstraintViolationException; import org.springframework.beans.factory.annotation.Autowired; @@ -44,6 +45,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.common.util.DebugModeUtil.DEBUG_MODE_DEFAULT_DURATION_MINUTES; import static org.thingsboard.server.dao.service.Validator.validateId; @@ -230,23 +232,29 @@ public class TenantProfileServiceImpl extends AbstractCachedEntityService>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(tenantProfileDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.TENANT_PROFILE; } - private final PaginatedRemover tenantProfilesRemover = - new PaginatedRemover<>() { + private final PaginatedRemover tenantProfilesRemover = new PaginatedRemover<>() { - @Override - protected PageData findEntities(TenantId tenantId, String id, PageLink pageLink) { - return tenantProfileDao.findTenantProfiles(tenantId, pageLink); - } + @Override + protected PageData findEntities(TenantId tenantId, String id, PageLink pageLink) { + return tenantProfileDao.findTenantProfiles(tenantId, pageLink); + } + + @Override + protected void removeEntity(TenantId tenantId, TenantProfile entity) { + removeTenantProfile(tenantId, entity, entity.isDefault()); + } - @Override - protected void removeEntity(TenantId tenantId, TenantProfile entity) { - removeTenantProfile(tenantId, entity, entity.isDefault()); - } - }; + }; } 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 0df7c36527..b6c5762d60 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 @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.tenant; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -51,6 +52,7 @@ import java.util.List; import java.util.Optional; import java.util.function.Consumer; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.service.Validator.validateId; @Service("TenantDaoService") @@ -75,6 +77,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService existsTenantCache; - @TransactionalEventListener(classes = TenantEvictEvent.class) @Override + @TransactionalEventListener public void handleEvictEvent(TenantEvictEvent event) { TenantId tenantId = event.getTenantId(); cache.evict(tenantId); @@ -245,6 +248,12 @@ public class TenantServiceImpl extends AbstractCachedEntityService>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(findTenantByIdAsync(tenantId, TenantId.fromUUID(entityId.getId()))) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.TENANT; diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java index f7bd64bef8..6818994629 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java @@ -113,6 +113,10 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD @Value("${cassandra.query.use_ts_key_value_partitioning_on_read:true}") private boolean useTsKeyValuePartitioningOnRead; + @Getter + @Value("${cassandra.query.use_ts_key_value_partitioning_on_read_max_estimated_partition_count:40}") // 3+ years for MONTHS + private int useTsKeyValuePartitioningOnReadMaxEstimatedPartitionCount; + @Value("${cassandra.query.ts_key_value_partitions_max_cache_size:100000}") private long partitionsCacheSize; @@ -415,22 +419,41 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD readResultsProcessingExecutor); } - private ListenableFuture> getPartitionsFuture(TenantId tenantId, TsKvQuery query, EntityId entityId, long minPartition, long maxPartition) { + ListenableFuture> getPartitionsFuture(TenantId tenantId, TsKvQuery query, EntityId entityId, long minPartition, long maxPartition) { if (isFixedPartitioning()) { //no need to fetch partitions from DB return Futures.immediateFuture(FIXED_PARTITION); } if (!isUseTsKeyValuePartitioningOnRead()) { - return Futures.immediateFuture(calculatePartitions(minPartition, maxPartition)); + final long estimatedPartitionCount = estimatePartitionCount(minPartition, maxPartition); + if (estimatedPartitionCount <= useTsKeyValuePartitioningOnReadMaxEstimatedPartitionCount) { + return Futures.immediateFuture(calculatePartitions(minPartition, maxPartition, (int) estimatedPartitionCount)); + } } + return getPartitionsFromDB(tenantId, query, entityId, minPartition, maxPartition); + } + + ListenableFuture> getPartitionsFromDB(TenantId tenantId, TsKvQuery query, EntityId entityId, long minPartition, long maxPartition) { TbResultSetFuture partitionsFuture = fetchPartitions(tenantId, entityId, query.getKey(), minPartition, maxPartition); return Futures.transformAsync(partitionsFuture, getPartitionsArrayFunction(), readResultsProcessingExecutor); } + // Optimistic estimation of partition count, expected to be never called for infinite partitioning + long estimatePartitionCount(long minPartition, long maxPartition) { + if (maxPartition > minPartition) { + return (maxPartition - minPartition) / tsFormat.getDurationMs() + 2; //at least 2 partitions, at max 2 partitions overestimated + } + return 1; // 1 or 0, but 1 is more optimistic + } + List calculatePartitions(long minPartition, long maxPartition) { + return calculatePartitions(minPartition, maxPartition, 0); + } + + List calculatePartitions(long minPartition, long maxPartition, int estimatedPartitionCount) { if (minPartition == maxPartition) { return Collections.singletonList(minPartition); } - List partitions = new ArrayList<>(); + List partitions = estimatedPartitionCount > 0 ? new ArrayList<>(estimatedPartitionCount) : new ArrayList<>(); long currentPartition = minPartition; LocalDateTime currentPartitionTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(currentPartition), ZoneOffset.UTC); diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/NoSqlTsPartitionDate.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/NoSqlTsPartitionDate.java index 873c33d4e3..c403a46df1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/NoSqlTsPartitionDate.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/NoSqlTsPartitionDate.java @@ -15,32 +15,29 @@ */ package org.thingsboard.server.dao.timeseries; +import lombok.Getter; + import java.time.LocalDateTime; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalUnit; import java.util.Optional; +import java.util.concurrent.TimeUnit; +@Getter public enum NoSqlTsPartitionDate { MINUTES("yyyy-MM-dd-HH-mm", ChronoUnit.MINUTES), HOURS("yyyy-MM-dd-HH", ChronoUnit.HOURS), DAYS("yyyy-MM-dd", ChronoUnit.DAYS), MONTHS("yyyy-MM", ChronoUnit.MONTHS), YEARS("yyyy", ChronoUnit.YEARS),INDEFINITE("",ChronoUnit.FOREVER); private final String pattern; private final transient TemporalUnit truncateUnit; + private final transient long durationMs; public final static LocalDateTime EPOCH_START = LocalDateTime.ofEpochSecond(0,0, ZoneOffset.UTC); NoSqlTsPartitionDate(String pattern, TemporalUnit truncateUnit) { this.pattern = pattern; this.truncateUnit = truncateUnit; - } - - - public String getPattern() { - return pattern; - } - - public TemporalUnit getTruncateUnit() { - return truncateUnit; + this.durationMs = TimeUnit.SECONDS.toMillis(this.truncateUnit.getDuration().getSeconds()); } public LocalDateTime truncatedTo(LocalDateTime time) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java index 1282493650..49ee56e7be 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/usagerecord/ApiUsageStateServiceImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.usagerecord; +import com.google.common.util.concurrent.FluentFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; @@ -48,11 +49,13 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.service.Validator.validateId; @Service("ApiUsageStateDaoService") @Slf4j public class ApiUsageStateServiceImpl extends AbstractEntityService implements ApiUsageStateService { + public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; private final ApiUsageStateDao apiUsageStateDao; @@ -161,7 +164,7 @@ public class ApiUsageStateServiceImpl extends AbstractEntityService implements A public ApiUsageState update(ApiUsageState apiUsageState) { log.trace("Executing save [{}]", apiUsageState.getTenantId()); validateId(apiUsageState.getTenantId(), id -> INCORRECT_TENANT_ID + id); - validateId(apiUsageState.getId(), "Can't save new usage state. Only update is allowed!"); + validateId(apiUsageState.getId(), __ -> "Can't save new usage state. Only update is allowed!"); apiUsageState.setVersion(null); ApiUsageState savedState = apiUsageStateDao.save(apiUsageState.getTenantId(), apiUsageState); eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(savedState.getTenantId()).entityId(savedState.getId()) @@ -195,6 +198,12 @@ public class ApiUsageStateServiceImpl extends AbstractEntityService implements A return Optional.ofNullable(findApiUsageStateById(tenantId, new ApiUsageStateId(entityId.getId()))); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(apiUsageStateDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Transactional @Override public void deleteByTenantId(TenantId tenantId) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserDao.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserDao.java index b60b263ac8..127aa6141a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserDao.java @@ -101,4 +101,5 @@ public interface UserDao extends Dao, TenantEntityDao { PageData findByAuthorityAndTenantProfilesIds(Authority authority, List tenantProfilesIds, PageLink pageLink); + int countTenantAdmins(UUID tenantId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java index 7cbf1c80bd..cf20683a52 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.user; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.ListenableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -44,6 +45,11 @@ import org.thingsboard.server.common.data.id.UserCredentialsId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.mobile.MobileSessionInfo; import org.thingsboard.server.common.data.mobile.UserMobileSessionInfo; +import org.thingsboard.server.common.data.notification.targets.platform.CustomerUsersFilter; +import org.thingsboard.server.common.data.notification.targets.platform.SystemLevelUsersFilter; +import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter; +import org.thingsboard.server.common.data.notification.targets.platform.UserListFilter; +import org.thingsboard.server.common.data.notification.targets.platform.UsersFilter; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.Authority; @@ -62,6 +68,7 @@ import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.settings.SecuritySettingsService; import org.thingsboard.server.dao.sql.JpaExecutorService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import java.util.ArrayList; import java.util.Collections; @@ -72,7 +79,10 @@ import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; +import static org.apache.commons.collections4.CollectionUtils.isNotEmpty; import static org.thingsboard.server.common.data.StringUtils.generateSafeToken; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validatePageLink; @@ -98,14 +108,15 @@ public class UserServiceImpl extends AbstractCachedEntityService userValidator; private final DataValidator userCredentialsValidator; private final ApplicationEventPublisher eventPublisher; private final EntityCountService countService; private final JpaExecutorService executor; - @TransactionalEventListener(classes = UserCacheEvictEvent.class) @Override + @TransactionalEventListener public void handleEvictEvent(UserCacheEvictEvent event) { List keys = new ArrayList<>(2); keys.add(new UserCacheKey(event.tenantId(), event.newEmail())); @@ -166,6 +177,10 @@ public class UserServiceImpl extends AbstractCachedEntityService doSaveUser(tenantId, user, doValidate)); + } + + private User doSaveUser(TenantId tenantId, User user, boolean doValidate) { log.trace("Executing saveUser [{}]", user); User oldUser = null; if (doValidate) { @@ -191,7 +206,7 @@ public class UserServiceImpl extends AbstractCachedEntityService findMobileSessionInfo(TenantId tenantId, UserId userId) { return Optional.ofNullable(userSettingsService.findUserSettings(tenantId, userId, UserSettingsType.MOBILE)) .map(UserSettings::getSettings).map(settings -> JacksonUtil.treeToValue(settings, UserMobileSessionInfo.class)); @@ -540,6 +560,80 @@ public class UserServiceImpl extends AbstractCachedEntityService findUsersByFilter(TenantId tenantId, UsersFilter filter, PageLink pageLink) { + switch (filter.getType()) { + case USER_LIST -> { + List users = ((UserListFilter) filter).getUsersIds().stream() + .limit(pageLink.getPageSize()) + .map(UserId::new).map(userId -> findUserById(tenantId, userId)) + .filter(Objects::nonNull).collect(Collectors.toList()); + return new PageData<>(users, 1, users.size(), false); + } + case CUSTOMER_USERS -> { + if (tenantId.equals(TenantId.SYS_TENANT_ID)) { + throw new IllegalArgumentException("Customer users target is not supported for system administrator"); + } + CustomerUsersFilter customerUsersFilter = (CustomerUsersFilter) filter; + return findCustomerUsers(tenantId, new CustomerId(customerUsersFilter.getCustomerId()), pageLink); + } + case TENANT_ADMINISTRATORS -> { + TenantAdministratorsFilter tenantAdministratorsFilter = (TenantAdministratorsFilter) filter; + if (!tenantId.equals(TenantId.SYS_TENANT_ID)) { + return findTenantAdmins(tenantId, pageLink); + } else { + if (isNotEmpty(tenantAdministratorsFilter.getTenantsIds())) { + return findTenantAdminsByTenantsIds(tenantAdministratorsFilter.getTenantsIds().stream() + .map(TenantId::fromUUID).collect(Collectors.toList()), pageLink); + } else if (isNotEmpty(tenantAdministratorsFilter.getTenantProfilesIds())) { + return findTenantAdminsByTenantProfilesIds(tenantAdministratorsFilter.getTenantProfilesIds().stream() + .map(TenantProfileId::new).collect(Collectors.toList()), pageLink); + } else { + return findAllTenantAdmins(pageLink); + } + } + } + case SYSTEM_ADMINISTRATORS -> { + return findSysAdmins(pageLink); + } + case ALL_USERS -> { + if (!tenantId.equals(TenantId.SYS_TENANT_ID)) { + return findUsersByTenantId(tenantId, pageLink); + } else { + return findAllUsers(pageLink); + } + } + default -> throw new IllegalArgumentException("Recipient type not supported"); + } + } + + @Override + public boolean matchesFilter(TenantId tenantId, SystemLevelUsersFilter filter, User user) { + switch (filter.getType()) { + case TENANT_ADMINISTRATORS -> { + if (user.isSystemAdmin() || user.isCustomerUser()) { + return false; + } + TenantAdministratorsFilter tenantAdministratorsFilter = (TenantAdministratorsFilter) filter; + if (isNotEmpty(tenantAdministratorsFilter.getTenantsIds())) { + return tenantAdministratorsFilter.getTenantsIds().contains(user.getTenantId().getId()); + } else if (isNotEmpty(tenantAdministratorsFilter.getTenantProfilesIds())) { + return tenantAdministratorsFilter.getTenantProfilesIds().contains(tenantProfileCache.get(user.getTenantId()).getUuidId()); + } else { + return user.getAuthority() == Authority.TENANT_ADMIN; + } + } + case SYSTEM_ADMINISTRATORS -> { + return user.getAuthority() == Authority.SYS_ADMIN; + } + case ALL_USERS -> { + return true; + } + default -> throw new IllegalArgumentException("Recipient type not supported"); + } + + } + private void updatePasswordHistory(UserCredentials userCredentials) { JsonNode additionalInfo = userCredentials.getAdditionalInfo(); if (!(additionalInfo instanceof ObjectNode)) { @@ -607,6 +701,12 @@ public class UserServiceImpl extends AbstractCachedEntityService>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(findUserByIdAsync(tenantId, new UserId(entityId.getId()))) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public long countByTenantId(TenantId tenantId) { return userDao.countByTenantId(tenantId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeServiceImpl.java index 7e74c2a18b..db8cd875e3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeServiceImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.widget; +import com.google.common.util.concurrent.FluentFuture; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -47,6 +48,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.server.dao.service.Validator.validateIds; @Service("WidgetTypeDaoService") @@ -54,8 +56,6 @@ import static org.thingsboard.server.dao.service.Validator.validateIds; public class WidgetTypeServiceImpl implements WidgetTypeService { public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; - public static final String INCORRECT_RESOURCE_ID = "Incorrect resourceId "; - public static final String INCORRECT_BUNDLE_ALIAS = "Incorrect bundleAlias "; public static final String INCORRECT_WIDGETS_BUNDLE_ID = "Incorrect widgetsBundleId "; @Autowired @@ -281,6 +281,12 @@ public class WidgetTypeServiceImpl implements WidgetTypeService { return Optional.ofNullable(findWidgetTypeById(tenantId, new WidgetTypeId(entityId.getId()))); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(widgetTypeDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.WIDGET_TYPE; @@ -303,6 +309,7 @@ public class WidgetTypeServiceImpl implements WidgetTypeService { protected void removeEntity(TenantId tenantId, WidgetTypeInfo entity) { deleteWidgetType(tenantId, new WidgetTypeId(entity.getUuidId())); } + }; private final PaginatedRemover bundleWidgetTypesRemover = new PaginatedRemover<>() { @@ -316,6 +323,7 @@ public class WidgetTypeServiceImpl implements WidgetTypeService { protected void removeEntity(TenantId tenantId, WidgetTypeInfo widgetTypeInfo) { deleteWidgetType(tenantId, widgetTypeInfo.getId()); } + }; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleServiceImpl.java index f057ce22ca..5b5930fb0a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/widget/WidgetsBundleServiceImpl.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.widget; import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.FluentFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; @@ -33,12 +34,10 @@ import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetsBundle; import org.thingsboard.server.common.data.widget.WidgetsBundleFilter; -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.exception.IncorrectParameterException; import org.thingsboard.server.dao.resource.ImageService; -import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.service.Validator; @@ -48,13 +47,15 @@ import java.util.List; import java.util.Optional; import java.util.stream.Stream; +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; +import static org.thingsboard.server.dao.entity.AbstractEntityService.checkConstraintViolation; + @Service("WidgetsBundleDaoService") @Slf4j public class WidgetsBundleServiceImpl implements WidgetsBundleService { private static final int DEFAULT_WIDGETS_BUNDLE_LIMIT = 300; public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; - public static final String INCORRECT_PAGE_LINK = "Incorrect page link "; @Autowired private WidgetsBundleDao widgetsBundleDao; @@ -71,9 +72,6 @@ public class WidgetsBundleServiceImpl implements WidgetsBundleService { @Autowired private ImageService imageService; - @Autowired - private ResourceService resourceService; - @Override public WidgetsBundle findWidgetsBundleById(TenantId tenantId, WidgetsBundleId widgetsBundleId) { log.trace("Executing findWidgetsBundleById [{}]", widgetsBundleId); @@ -92,9 +90,9 @@ public class WidgetsBundleServiceImpl implements WidgetsBundleService { .entityId(result.getId()).created(widgetsBundle.getId() == null).build()); return result; } catch (Exception e) { - AbstractCachedEntityService.checkConstraintViolation(e, - "uq_widgets_bundle_alias", "Widgets Bundle with such alias already exists!"); - AbstractCachedEntityService.checkConstraintViolation(e, "widgets_bundle_external_id_unq_key", "Widgets Bundle with such external id already exists!"); + checkConstraintViolation(e, + "uq_widgets_bundle_alias", "Widgets Bundle with such alias already exists!", + "widgets_bundle_external_id_unq_key", "Widgets Bundle with such external id already exists!"); throw e; } } @@ -248,9 +246,7 @@ public class WidgetsBundleServiceImpl implements WidgetsBundleService { } if (widgetsBundleDescriptor.has("widgetTypeFqns")) { JsonNode widgetFqnsArrayJson = widgetsBundleDescriptor.get("widgetTypeFqns"); - widgetFqnsArrayJson.forEach(fqnJson -> { - widgetTypeFqns.add(fqnJson.asText()); - }); + widgetFqnsArrayJson.forEach(fqnJson -> widgetTypeFqns.add(fqnJson.asText())); } widgetTypeService.updateWidgetsBundleWidgetFqns(TenantId.SYS_TENANT_ID, widgetsBundle.getId(), widgetTypeFqns); }); @@ -274,23 +270,29 @@ public class WidgetsBundleServiceImpl implements WidgetsBundleService { return Optional.ofNullable(findWidgetsBundleById(tenantId, new WidgetsBundleId(entityId.getId()))); } + @Override + public FluentFuture>> findEntityAsync(TenantId tenantId, EntityId entityId) { + return FluentFuture.from(widgetsBundleDao.findByIdAsync(tenantId, entityId.getId())) + .transform(Optional::ofNullable, directExecutor()); + } + @Override public EntityType getEntityType() { return EntityType.WIDGETS_BUNDLE; } - private PaginatedRemover tenantWidgetsBundleRemover = - new PaginatedRemover() { + private final PaginatedRemover tenantWidgetsBundleRemover = new PaginatedRemover<>() { - @Override - protected PageData findEntities(TenantId tenantId, TenantId id, PageLink pageLink) { - return widgetsBundleDao.findTenantWidgetsBundlesByTenantId(id.getId(), pageLink); - } + @Override + protected PageData findEntities(TenantId tenantId, TenantId id, PageLink pageLink) { + return widgetsBundleDao.findTenantWidgetsBundlesByTenantId(id.getId(), pageLink); + } + + @Override + protected void removeEntity(TenantId tenantId, WidgetsBundle entity) { + deleteWidgetsBundle(tenantId, new WidgetsBundleId(entity.getUuidId())); + } - @Override - protected void removeEntity(TenantId tenantId, WidgetsBundle entity) { - deleteWidgetsBundle(tenantId, new WidgetsBundleId(entity.getUuidId())); - } - }; + }; } diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 6ccf2f6d95..4722d4dafa 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -923,17 +923,7 @@ CREATE TABLE IF NOT EXISTS calculated_field ( configuration varchar(1000000), version BIGINT DEFAULT 1, debug_settings varchar(1024), - CONSTRAINT calculated_field_unq_key UNIQUE (entity_id, name) -); - -CREATE TABLE IF NOT EXISTS calculated_field_link ( - id uuid NOT NULL CONSTRAINT calculated_field_link_pkey PRIMARY KEY, - created_time bigint NOT NULL, - tenant_id uuid NOT NULL, - entity_type VARCHAR(32), - entity_id uuid NOT NULL, - calculated_field_id uuid NOT NULL, - CONSTRAINT fk_calculated_field_id FOREIGN KEY (calculated_field_id) REFERENCES calculated_field(id) ON DELETE CASCADE + CONSTRAINT calculated_field_unq_key UNIQUE (entity_id, type, name) ); CREATE TABLE IF NOT EXISTS cf_debug_event ( diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java index 25339244fd..0d2b9b5d9f 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java @@ -48,6 +48,7 @@ import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.oauth2.MapperType; import org.thingsboard.server.common.data.oauth2.OAuth2Client; import org.thingsboard.server.common.data.oauth2.OAuth2CustomMapperConfig; @@ -185,8 +186,13 @@ public abstract class AbstractServiceTest { } public Tenant createTenant() { + return createTenant(null); + } + + public Tenant createTenant(TenantProfileId tenantProfileId) { Tenant tenant = new Tenant(); tenant.setTitle("My tenant " + UUID.randomUUID()); + tenant.setTenantProfileId(tenantProfileId); Tenant savedTenant = tenantService.saveTenant(tenant); assertNotNull(savedTenant); return savedTenant; diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java index 849528b091..1b8a08defc 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java @@ -211,7 +211,7 @@ public class AlarmServiceTest extends AbstractServiceTest { Assert.assertNotNull(alarms.getData()); Assert.assertEquals(0, alarms.getData().size()); - alarmService.clearAlarm(tenantId, created.getId(), System.currentTimeMillis(), null); + alarmService.clearAlarm(tenantId, created.getId(), System.currentTimeMillis(), null, true); created = alarmService.findAlarmInfoById(tenantId, created.getId()); alarms = alarmService.findAlarms(tenantId, AlarmQuery.builder() @@ -319,7 +319,7 @@ public class AlarmServiceTest extends AbstractServiceTest { Assert.assertNotNull(alarms.getData()); Assert.assertEquals(0, alarms.getData().size()); - alarmService.clearAlarm(tenantId, created.getId(), System.currentTimeMillis(), null); + alarmService.clearAlarm(tenantId, created.getId(), System.currentTimeMillis(), null, true); created = alarmService.findAlarmInfoById(tenantId, created.getId()); alarms = alarmService.findAlarmsV2(tenantId, AlarmQueryV2.builder() @@ -622,7 +622,7 @@ public class AlarmServiceTest extends AbstractServiceTest { .severity(AlarmSeverity.MAJOR) .startTs(System.currentTimeMillis()).build()); AlarmInfo alarm1 = result.getAlarm(); - alarmService.clearAlarm(tenantId, alarm1.getId(), System.currentTimeMillis(), null); + alarmService.clearAlarm(tenantId, alarm1.getId(), System.currentTimeMillis(), null, true); result = alarmService.createAlarm(AlarmCreateOrUpdateActiveRequest.builder() .tenantId(tenantId) @@ -632,7 +632,7 @@ public class AlarmServiceTest extends AbstractServiceTest { .startTs(System.currentTimeMillis()).build()); AlarmInfo alarm2 = result.getAlarm(); alarmService.acknowledgeAlarm(tenantId, alarm2.getId(), System.currentTimeMillis()); - alarmService.clearAlarm(tenantId, alarm2.getId(), System.currentTimeMillis(), null); + alarmService.clearAlarm(tenantId, alarm2.getId(), System.currentTimeMillis(), null, true); result = alarmService.createAlarm(AlarmCreateOrUpdateActiveRequest.builder() .tenantId(tenantId) @@ -852,7 +852,7 @@ public class AlarmServiceTest extends AbstractServiceTest { Assert.assertEquals(1, alarmsCount); - alarmService.clearAlarm(tenantId, created.getId(), System.currentTimeMillis(), null); + alarmService.clearAlarm(tenantId, created.getId(), System.currentTimeMillis(), null, true); created = alarmService.findAlarmInfoById(tenantId, created.getId()); alarmsCount = alarmService.countAlarmsByQuery(tenantId, null, countQuery); @@ -980,7 +980,7 @@ public class AlarmServiceTest extends AbstractServiceTest { alarmsCount = alarmService.countAlarmsByQuery(tenantId, null, countQuery, List.of(childId)); Assert.assertEquals(0, alarmsCount); - alarmService.clearAlarm(tenantId, created.getId(), System.currentTimeMillis(), null); + alarmService.clearAlarm(tenantId, created.getId(), System.currentTimeMillis(), null, true); countQuery.setStatusList(List.of(AlarmSearchStatus.CLEARED)); alarmsCount = alarmService.countAlarmsByQuery(tenantId, null, countQuery, List.of(childId)); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AssetServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AssetServiceTest.java index 462e7a894c..b7f208b8c7 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AssetServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AssetServiceTest.java @@ -16,17 +16,25 @@ package org.thingsboard.server.dao.service; import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import org.junit.AfterClass; import org.junit.Assert; +import org.junit.BeforeClass; import org.junit.Test; import org.junit.jupiter.api.Assertions; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; +import org.testcontainers.shaded.org.awaitility.Awaitility; +import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -44,6 +52,8 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.dao.asset.AssetDao; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -51,12 +61,16 @@ import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.tenant.TenantProfileService; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; @@ -72,13 +86,28 @@ public class AssetServiceTest extends AbstractServiceTest { @Autowired RelationService relationService; @Autowired + TenantProfileService tenantProfileService; + @Autowired private AssetProfileService assetProfileService; @Autowired private CalculatedFieldService calculatedFieldService; @Autowired private PlatformTransactionManager platformTransactionManager; + private static ListeningExecutorService executor; + private IdComparator idComparator = new IdComparator<>(); + private TenantId anotherTenantId; + + @BeforeClass + public static void before() { + executor = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10, ThingsBoardThreadFactory.forName("AssetServiceTestScope"))); + } + + @AfterClass + public static void after() { + executor.shutdownNow(); + } @Test public void testSaveAsset() { @@ -105,6 +134,39 @@ public class AssetServiceTest extends AbstractServiceTest { assetService.deleteAsset(tenantId, savedAsset.getId()); } + @Test + public void testAssetLimitOnTenantProfileLevel() throws InterruptedException { + TenantProfile tenantProfile = new TenantProfile(); + tenantProfile.setName("Test profile"); + tenantProfile.setDescription("Test"); + TenantProfileData profileData = new TenantProfileData(); + profileData.setConfiguration(DefaultTenantProfileConfiguration.builder().maxAssets(5l).build()); + tenantProfile.setProfileData(profileData); + tenantProfile.setDefault(false); + tenantProfile.setIsolatedTbRuleEngine(false); + + tenantProfile = tenantProfileService.saveTenantProfile(anotherTenantId, tenantProfile); + anotherTenantId = createTenant(tenantProfile.getId()).getId(); + + for (int i = 0; i < 20; i++) { + executor.submit(() -> { + Asset asset = new Asset(); + asset.setTenantId(anotherTenantId); + asset.setName(RandomStringUtils.randomAlphabetic(10)); + asset.setType("default"); + assetService.saveAsset(asset); + }); + } + + Awaitility.await().atMost(10, TimeUnit.SECONDS).until(() -> { + long countByTenantId = assetService.countByTenantId(anotherTenantId); + return countByTenantId == 5; + }); + + Thread.sleep(2000); + assertThat(assetService.countByTenantId(anotherTenantId)).isEqualTo(5); + } + @Test public void testShouldNotPutInCacheRolledbackAssetProfile() { AssetProfile assetProfile = new AssetProfile(); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java index 49bdcca0fb..c904852fbe 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java @@ -15,13 +15,8 @@ */ package org.thingsboard.server.dao.service; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; -import org.junit.After; -import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; @@ -31,7 +26,7 @@ import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfig import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; -import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; @@ -39,15 +34,19 @@ import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupC import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS; @DaoSqlTest @@ -60,18 +59,6 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { @Autowired private TbTenantProfileCache tbTenantProfileCache; - private ListeningExecutorService executor; - - @Before - public void before() { - executor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(8, getClass())); - } - - @After - public void after() { - executor.shutdownNow(); - } - @Test public void testSaveCalculatedField() { Device device = createTestDevice(); @@ -99,53 +86,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { } @Test - public void testSaveGeofencingCalculatedField_shouldNotChangeScheduledInterval() { - // Arrange a device - Device device = createTestDevice(); - - // Build a valid Geofencing configuration - GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration(); - - // Coordinates: TS_LATEST, no dynamic source - EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude"); - cfg.setEntityCoordinates(entityCoordinates); - - // Zone-group argument (ATTRIBUTE) — no DYNAMIC configuration, so no scheduling even if the scheduled interval is set - ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - zoneGroupConfiguration.setRefEntityId(device.getId()); - cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration)); - - // Set a scheduled interval to some value - cfg.setScheduledUpdateInterval(600); - - // Create & save Calculated Field - CalculatedField cf = new CalculatedField(); - cf.setTenantId(tenantId); - cf.setEntityId(device.getId()); - cf.setType(CalculatedFieldType.GEOFENCING); - cf.setName("GF clamp test"); - cf.setConfigurationVersion(0); - cf.setConfiguration(cfg); - - CalculatedField saved = calculatedFieldService.save(cf); - - assertThat(saved).isNotNull(); - assertThat(saved.getConfiguration()).isInstanceOf(GeofencingCalculatedFieldConfiguration.class); - - var geofencingCalculatedFieldConfiguration = (GeofencingCalculatedFieldConfiguration) saved.getConfiguration(); - - // Assert: the interval is saved, but scheduling is not enabled - int savedInterval = geofencingCalculatedFieldConfiguration.getScheduledUpdateInterval(); - boolean scheduledUpdateEnabled = geofencingCalculatedFieldConfiguration.isScheduledUpdateEnabled(); - - assertThat(savedInterval).isEqualTo(600); - assertThat(scheduledUpdateEnabled).isFalse(); - - calculatedFieldService.deleteCalculatedField(tenantId, saved.getId()); - } - - @Test - public void testSaveGeofencingCalculatedField_shouldThrowWhenScheduledIntervalIsLessThanMinAllowedIntervalInTenantProfile() { + public void testSaveGeofencingCalculatedField_shouldThrowWhenScheduledIntervalLessThanMinAllowedIntervalInTenantProfile() { // Arrange a device Device device = createTestDevice(); @@ -158,22 +99,27 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { // Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - var dynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - dynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM); - dynamicSourceConfiguration.setMaxLevel(1); - dynamicSourceConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE); + var dynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + dynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE))); zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration); cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration)); + // Get tenant profile min. + int min = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedScheduledUpdateIntervalInSecForCF(); + int valueFromConfig = min - 10; + // Enable scheduling with an interval below tenant min - cfg.setScheduledUpdateInterval(600); + cfg.setScheduledUpdateEnabled(true); + cfg.setScheduledUpdateInterval(valueFromConfig); // Create & save Calculated Field CalculatedField cf = new CalculatedField(); cf.setTenantId(tenantId); cf.setEntityId(device.getId()); cf.setType(CalculatedFieldType.GEOFENCING); - cf.setName("GF clamp test"); + cf.setName("GF min allowed scheduled update interval test"); cf.setConfigurationVersion(0); cf.setConfiguration(cfg); @@ -181,27 +127,34 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { .isInstanceOf(DataValidationException.class) .hasCauseInstanceOf(IllegalArgumentException.class) .hasMessageStartingWith("Scheduled update interval is less than configured " + - "minimum allowed interval in tenant profile: "); + "minimum allowed interval in tenant profile: "); } @Test - public void testSaveGeofencingCalculatedField_shouldThrowWhenRelationLevelIsGreaterThanMaxAllowedRelationLevelInTenantProfile() { + public void testSaveGeofencingCalculatedField_shouldThrowWhenRelationLevelGreaterThanMaxAllowedRelationLevelInTenantProfile() { // Arrange a device Device device = createTestDevice(); - // Build a valid Geofencing configuration GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration(); // Coordinates: TS_LATEST, no dynamic source EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude"); cfg.setEntityCoordinates(entityCoordinates); - // Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled - ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration( "allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - var dynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - dynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM); - dynamicSourceConfiguration.setMaxLevel(Integer.MAX_VALUE); - dynamicSourceConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE); + int maxRelationLevel = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMaxRelationLevelPerCfArgument(); + + // Zone-group argument (ATTRIBUTE) + ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + var dynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + + List levels = new ArrayList<>(); + for (int i = 0; i < maxRelationLevel + 1; i++) { + levels.add(mock(RelationPathLevel.class)); + } + + dynamicSourceConfiguration.setLevels(levels); zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration); cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration)); @@ -210,7 +163,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { cf.setTenantId(tenantId); cf.setEntityId(device.getId()); cf.setType(CalculatedFieldType.GEOFENCING); - cf.setName("GF clamp test"); + cf.setName("GF max relation level test"); cf.setConfigurationVersion(0); cf.setConfiguration(cfg); @@ -221,7 +174,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { } @Test - public void testSaveGeofencingCalculatedField_shouldUseScheduledIntervalFromConfig() { + public void testSaveGeofencingCalculatedField_shouldSaveWithoutDataValidationExceptionOnScheduledUpdateInterval() { // Arrange a device Device device = createTestDevice(); @@ -233,11 +186,9 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { cfg.setEntityCoordinates(entityCoordinates); // Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled - ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration( "allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - var dynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - dynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM); - dynamicSourceConfiguration.setMaxLevel(1); - dynamicSourceConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE); + ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + var dynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + dynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE))); zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration); cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration)); @@ -245,10 +196,10 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { int min = tbTenantProfileCache.get(tenantId) .getDefaultProfileConfiguration() .getMinAllowedScheduledUpdateIntervalInSecForCF(); - + int valueFromConfig = min + 100; // Enable scheduling with an interval greater than tenant min - int valueFromConfig = min + 100; + cfg.setScheduledUpdateEnabled(true); cfg.setScheduledUpdateInterval(valueFromConfig); // Create & save Calculated Field @@ -256,7 +207,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { cf.setTenantId(tenantId); cf.setEntityId(device.getId()); cf.setType(CalculatedFieldType.GEOFENCING); - cf.setName("GF no clamp test"); + cf.setName("GF no validation error test"); cf.setConfigurationVersion(0); cf.setConfiguration(cfg); @@ -267,7 +218,6 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { var geofencingCalculatedFieldConfiguration = (GeofencingCalculatedFieldConfiguration) saved.getConfiguration(); - // Assert: the interval is clamped up to tenant profile min (or stays >= original if already >= min) int savedInterval = geofencingCalculatedFieldConfiguration.getScheduledUpdateInterval(); assertThat(savedInterval).isEqualTo(valueFromConfig); @@ -282,7 +232,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { assertThatThrownBy(() -> calculatedFieldService.save(calculatedField)) .isInstanceOf(DataValidationException.class) - .hasMessage("Calculated Field with such name is already in exists!"); + .hasMessage("Calculated field with such name and type already exists"); } @Test diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/DeviceCredentialsServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/DeviceCredentialsServiceTest.java index 636b1a2ba5..d5c969b6f3 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/DeviceCredentialsServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/DeviceCredentialsServiceTest.java @@ -19,7 +19,10 @@ import com.datastax.oss.driver.api.core.uuid.Uuids; import org.junit.Assert; import org.junit.Test; import org.junit.jupiter.api.Assertions; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.id.DeviceCredentialsId; import org.thingsboard.server.common.data.id.DeviceId; @@ -27,6 +30,7 @@ import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.dao.device.DeviceCredentialsService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent; import org.thingsboard.server.dao.exception.DataValidationException; @DaoSqlTest @@ -36,6 +40,8 @@ public class DeviceCredentialsServiceTest extends AbstractServiceTest { DeviceCredentialsService deviceCredentialsService; @Autowired DeviceService deviceService; + @MockitoBean + ApplicationEventPublisher eventPublisher; @Test public void testCreateDeviceCredentials() { @@ -185,5 +191,39 @@ public class DeviceCredentialsServiceTest extends AbstractServiceTest { Assert.assertEquals(deviceCredentials, foundDeviceCredentials); deviceService.deleteDevice(tenantId, savedDevice.getId()); } -} + @Test + public void testUpdateDeviceCredentialsWithSameValuesDoesNotPublishEvent() { + Device device = new Device(); + device.setTenantId(tenantId); + device.setName("My device"); + device.setType("default"); + Device savedDevice = deviceService.saveDevice(device); + + try { + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(tenantId, savedDevice.getId()); + Assert.assertNotNull(deviceCredentials); + + DeviceCredentials updatedCredentials = new DeviceCredentials(deviceCredentials.getId()); + updatedCredentials.setDeviceId(deviceCredentials.getDeviceId()); + updatedCredentials.setCredentialsType(deviceCredentials.getCredentialsType()); + updatedCredentials.setCredentialsId(deviceCredentials.getCredentialsId()); + updatedCredentials.setCredentialsValue(deviceCredentials.getCredentialsValue()); + + Mockito.reset(eventPublisher); + + DeviceCredentials result = deviceCredentialsService.updateDeviceCredentials(tenantId, updatedCredentials); + + Assert.assertEquals(deviceCredentials.getCredentialsId(), result.getCredentialsId()); + Assert.assertEquals(deviceCredentials.getCredentialsType(), result.getCredentialsType()); + Assert.assertEquals(deviceCredentials.getCredentialsValue(), result.getCredentialsValue()); + Assert.assertEquals(deviceCredentials.getDeviceId(), result.getDeviceId()); + + Mockito.verify(eventPublisher, Mockito.never()).publishEvent(Mockito.any(ActionEntityEvent.class)); + + } finally { + deviceService.deleteDevice(tenantId, savedDevice.getId()); + } + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java index 32767043d7..1ad1ec082a 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java @@ -16,9 +16,13 @@ package org.thingsboard.server.dao.service; import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; import org.junit.After; +import org.junit.AfterClass; import org.junit.Assert; import org.junit.Before; +import org.junit.BeforeClass; import org.junit.Test; import org.junit.jupiter.api.Assertions; import org.mockito.Mockito; @@ -27,6 +31,8 @@ import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.testcontainers.shaded.org.awaitility.Awaitility; +import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceInfo; @@ -74,7 +80,10 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.thingsboard.server.common.data.ota.OtaPackageType.FIRMWARE; @@ -105,6 +114,17 @@ public class DeviceServiceTest extends AbstractServiceTest { private IdComparator idComparator = new IdComparator<>(); private TenantId anotherTenantId; + private static ListeningExecutorService executor; + + @BeforeClass + public static void beforeClass() { + executor = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10, ThingsBoardThreadFactory.forName("DeviceServiceTestScope"))); + } + + @AfterClass + public static void afterClass() { + executor.shutdownNow(); + } @Before public void before() { @@ -136,6 +156,31 @@ public class DeviceServiceTest extends AbstractServiceTest { deleteDevice(tenantId, device); } + @Test + public void testDeviceLimitOnTenantProfileLevel() throws InterruptedException { + TenantProfile defaultTenantProfile = tenantProfileService.findDefaultTenantProfile(tenantId); + defaultTenantProfile.getProfileData().setConfiguration(DefaultTenantProfileConfiguration.builder().maxDevices(5l).build()); + tenantProfileService.saveTenantProfile(tenantId, defaultTenantProfile); + + for (int i = 0; i < 20; i++) { + executor.submit(() -> { + Device device = new Device(); + device.setTenantId(tenantId); + device.setName(StringUtils.randomAlphabetic(10)); + device.setType("default"); + deviceService.saveDevice(device, true); + }); + } + + Awaitility.await().atMost(10, TimeUnit.SECONDS).until(() -> { + long countByTenantId = deviceService.countByTenantId(tenantId); + return countByTenantId == 5; + }); + + Thread.sleep(2000); + assertThat(deviceService.countByTenantId(tenantId)).isEqualTo(5); + } + @Test public void testSaveDevicesWithMaxDeviceOutOfLimit() { TenantProfile defaultTenantProfile = tenantProfileService.findDefaultTenantProfile(tenantId); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceRegistryTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceRegistryTest.java index 9503f4e23f..0e7797ce56 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceRegistryTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/EntityServiceRegistryTest.java @@ -20,7 +20,6 @@ import org.junit.Assert; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.entity.EntityDaoService; import org.thingsboard.server.dao.entity.EntityServiceRegistry; import org.thingsboard.server.dao.rule.RuleChainService; @@ -45,8 +44,4 @@ public class EntityServiceRegistryTest extends AbstractServiceTest { Assert.assertTrue(entityServiceRegistry.getServiceByEntityType(EntityType.RULE_NODE) instanceof RuleChainService); } - @Test - public void givenCalculatedFieldLinkEntityType_whenGetServiceByEntityTypeCalled_thenReturnedCalculatedFieldService() { - Assert.assertTrue(entityServiceRegistry.getServiceByEntityType(EntityType.CALCULATED_FIELD_LINK) instanceof CalculatedFieldService); - } } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/OtaPackageServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/OtaPackageServiceTest.java index 39fb69ffce..70f2ff119d 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/OtaPackageServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/OtaPackageServiceTest.java @@ -53,6 +53,7 @@ import static org.thingsboard.server.common.data.ota.OtaPackageType.FIRMWARE; public class OtaPackageServiceTest extends AbstractServiceTest { public static final String TITLE = "My firmware"; + public static final String TARGET_FW_VERSION = "fw.v.1.5.0-update"; private static final String FILE_NAME = "filename.txt"; private static final String VERSION = "v1.0"; private static final String CONTENT_TYPE = "text/plain"; diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java index 063e35ae80..e3827d6a6e 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java @@ -28,13 +28,16 @@ import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; import org.thingsboard.server.common.data.relation.EntityRelationsQuery; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.relation.RelationsSearchParameters; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import java.util.ArrayList; import java.util.Collections; @@ -42,12 +45,17 @@ import java.util.LinkedList; import java.util.List; import java.util.concurrent.ExecutionException; +import static org.assertj.core.api.Assertions.assertThat; + @DaoSqlTest public class RelationServiceTest extends AbstractServiceTest { @Autowired RelationService relationService; + @Autowired + private TbTenantProfileCache tbTenantProfileCache; + @Before public void before() { } @@ -348,14 +356,14 @@ public class RelationServiceTest extends AbstractServiceTest { query.setFilters(Collections.singletonList(new RelationEntityTypeFilter(EntityRelation.CONTAINS_TYPE, Collections.singletonList(EntityType.ASSET)))); List relations = relationService.findByQuery(SYSTEM_TENANT_ID, query).get(); Assert.assertEquals(expected.size(), relations.size()); - for(EntityRelation r : expected){ + for (EntityRelation r : expected) { Assert.assertTrue(relations.contains(r)); } //Test from cache relations = relationService.findByQuery(SYSTEM_TENANT_ID, query).get(); Assert.assertEquals(expected.size(), relations.size()); - for(EntityRelation r : expected){ + for (EntityRelation r : expected) { Assert.assertTrue(relations.contains(r)); } } @@ -623,6 +631,114 @@ public class RelationServiceTest extends AbstractServiceTest { Assert.assertTrue(relations.contains(relationF)); } + @Test + public void testFindByPathQueryWithoutExceedingLimit() throws Exception { + /* + A + └──[firstLevel, TO]→ B + └──[secondLevel, TO]→ C + ├──[thirdLevel, FROM]→ D1 + ├──[thirdLevel, FROM]→ D2 + ├──[thirdLevel, FROM]→ ... + └──[thirdLevel, FROM]→ D{N - 1}, where N is the limit + */ + AssetId assetA = new AssetId(Uuids.timeBased()); + AssetId assetB = new AssetId(Uuids.timeBased()); + AssetId assetC = new AssetId(Uuids.timeBased()); + + // create first and second level + saveRelation(new EntityRelation(assetB, assetA, "firstLevel")); + saveRelation(new EntityRelation(assetC, assetB, "secondLevel")); + + int limit = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMaxRelatedEntitiesToReturnPerCfArgument(); + + int totalCreated = limit - 1; + + List allThirdLevelRelations = new ArrayList<>(); + for (int i = 0; i < totalCreated; i++) { + AssetId leaf = new AssetId(Uuids.timeBased()); + allThirdLevelRelations.add(saveRelation(new EntityRelation(assetC, leaf, "thirdLevel"))); + } + + EntityRelationPathQuery query = new EntityRelationPathQuery(assetA, List.of( + new RelationPathLevel(EntitySearchDirection.TO, "firstLevel"), + new RelationPathLevel(EntitySearchDirection.TO, "secondLevel"), + new RelationPathLevel(EntitySearchDirection.FROM, "thirdLevel") + )); + + // call a method that applies the default limit internally + List result = relationService.findByRelationPathQueryAsync(tenantId, query).get(); + + // verify that limit has been applied + assertThat(result).hasSize(totalCreated); + + // verify all returned are valid third-level relations under C + assertThat(result) + .allSatisfy(rel -> { + assertThat(rel.getType()).isEqualTo("thirdLevel"); + assertThat(rel.getFrom()).isEqualTo(assetC); + }); + + // verify the returned subset is part of all created relations + assertThat(result).isEqualTo(allThirdLevelRelations); + } + + @Test + public void testFindByPathQueryWithExceedingLimit() throws Exception { + /* + A + └──[firstLevel, TO]→ B + └──[secondLevel, TO]→ C + ├──[thirdLevel, FROM]→ D1 + ├──[thirdLevel, FROM]→ D2 + ├──[thirdLevel, FROM]→ ... + └──[thirdLevel, FROM]→ D{N + 20}, where N is the limit + */ + AssetId assetA = new AssetId(Uuids.timeBased()); + AssetId assetB = new AssetId(Uuids.timeBased()); + AssetId assetC = new AssetId(Uuids.timeBased()); + + // create first and second level + saveRelation(new EntityRelation(assetB, assetA, "firstLevel")); + saveRelation(new EntityRelation(assetC, assetB, "secondLevel")); + + int limit = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMaxRelatedEntitiesToReturnPerCfArgument(); + + int totalCreated = limit + 20; + + List allThirdLevelRelations = new ArrayList<>(); + for (int i = 0; i < totalCreated; i++) { + AssetId leaf = new AssetId(Uuids.timeBased()); + allThirdLevelRelations.add(saveRelation(new EntityRelation(assetC, leaf, "thirdLevel"))); + } + + EntityRelationPathQuery query = new EntityRelationPathQuery(assetA, List.of( + new RelationPathLevel(EntitySearchDirection.TO, "firstLevel"), + new RelationPathLevel(EntitySearchDirection.TO, "secondLevel"), + new RelationPathLevel(EntitySearchDirection.FROM, "thirdLevel") + )); + + // call a method that applies the default limit internally + List result = relationService.findByRelationPathQueryAsync(tenantId, query).get(); + + // verify that limit has been applied + assertThat(result).hasSize(limit); + + // verify all returned are valid third-level relations under C + assertThat(result) + .allSatisfy(rel -> { + assertThat(rel.getType()).isEqualTo("thirdLevel"); + assertThat(rel.getFrom()).isEqualTo(assetC); + }); + + // verify the returned subset is part of all created relations + assertThat(result).isSubsetOf(allThirdLevelRelations); + } + @Test public void testFindByQueryLargeHierarchyFetchAllWithUnlimLvl() throws Exception { AssetId rootAsset = new AssetId(Uuids.timeBased()); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidatorTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidatorTest.java index 43fb44431e..0d52f999de 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidatorTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidatorTest.java @@ -17,8 +17,8 @@ package org.thingsboard.server.dao.service.validator; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.id.CalculatedFieldId; @@ -38,11 +38,11 @@ public class CalculatedFieldDataValidatorTest { private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("7b5229e9-166e-41a9-a257-3b1dafad1b04")); private final CalculatedFieldId CALCULATED_FIELD_ID = new CalculatedFieldId(UUID.fromString("060fbe45-fbb2-4549-abf3-f72a6be3cb9f")); - @MockBean + @MockitoBean private CalculatedFieldDao calculatedFieldDao; - @MockBean + @MockitoBean private DefaultApiLimitService apiLimitService; - @SpyBean + @MockitoSpyBean private CalculatedFieldDataValidator validator; @Test diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/validator/CalculatedFieldLinkDataValidatorTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/validator/CalculatedFieldLinkDataValidatorTest.java deleted file mode 100644 index c477498602..0000000000 --- a/dao/src/test/java/org/thingsboard/server/dao/service/validator/CalculatedFieldLinkDataValidatorTest.java +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Copyright © 2016-2025 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.test.mock.mockito.SpyBean; -import org.thingsboard.server.common.data.cf.CalculatedFieldLink; -import org.thingsboard.server.common.data.id.CalculatedFieldId; -import org.thingsboard.server.common.data.id.CalculatedFieldLinkId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.cf.CalculatedFieldLinkDao; -import org.thingsboard.server.dao.exception.DataValidationException; - -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.BDDMockito.given; - -@SpringBootTest(classes = CalculatedFieldLinkDataValidator.class) -public class CalculatedFieldLinkDataValidatorTest { - - private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("2ba09d99-6143-43dc-b645-381fc0c43ebe")); - private final CalculatedFieldLinkId CALCULATED_FIELD_LINK_ID = new CalculatedFieldLinkId(UUID.fromString("a5609ef4-cb42-43ce-9b23-e090a4878d1c")); - - @MockBean - private CalculatedFieldLinkDao calculatedFieldLinkDao; - @SpyBean - private CalculatedFieldLinkDataValidator validator; - - @Test - public void testUpdateNonExistingCalculatedField() { - CalculatedFieldLink calculatedFieldLink = new CalculatedFieldLink(CALCULATED_FIELD_LINK_ID); - calculatedFieldLink.setCalculatedFieldId(new CalculatedFieldId(UUID.fromString("136477af-fd07-4498-b9c9-54fe50e82992"))); - - given(calculatedFieldLinkDao.findById(TENANT_ID, CALCULATED_FIELD_LINK_ID.getId())).willReturn(null); - - assertThatThrownBy(() -> validator.validateUpdate(TENANT_ID, calculatedFieldLink)) - .isInstanceOf(DataValidationException.class) - .hasMessage("Can't update non existing calculated field link!"); - } - -} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidatorTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidatorTest.java index b69165be7c..f9d6c035dc 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidatorTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidatorTest.java @@ -48,6 +48,9 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.willReturn; import static org.mockito.Mockito.verify; +import static org.thingsboard.server.common.data.device.credentials.lwm2m.Lwm2mServerIdentifier.LWM2M_SERVER_MAX; +import static org.thingsboard.server.common.data.device.credentials.lwm2m.Lwm2mServerIdentifier.NOT_USED_IDENTIFYING_LWM2M_SERVER_MAX; +import static org.thingsboard.server.common.data.device.credentials.lwm2m.Lwm2mServerIdentifier.PRIMARY_LWM2M_SERVER; @SpringBootTest(classes = DeviceProfileDataValidator.class) class DeviceProfileDataValidatorTest { @@ -74,8 +77,8 @@ class DeviceProfileDataValidatorTest { " \"clientOnlyObserveAfterConnect\": 1\n" + " }"; - private static final String msgErrorLwm2mRange = "LwM2M Server ShortServerId must be in range [1 - 65534]!"; - private static final String msgErrorBsRange = "Bootstrap Server ShortServerId must be in range [0 - 65535]!"; + private static final String msgErrorLwm2mRange = "LwM2M Server ShortServerId must be in range [" + PRIMARY_LWM2M_SERVER.getId() + " - " + LWM2M_SERVER_MAX.getId() + "]!"; + private static final String msgErrorBsRange = "Bootstrap Server ShortServerId must be null!"; private static final String msgErrorNotNull = " Server ShortServerId must not be null!"; private static final String host = "localhost"; private static final String hostBs = "localhost"; @@ -124,7 +127,7 @@ class DeviceProfileDataValidatorTest { @Test void testValidateDeviceProfile_Lwm2mBootstrap_ShortServerId_Ok() { Integer shortServerId = 123; - Integer shortServerIdBs = 0; + Integer shortServerIdBs = null; DeviceProfile deviceProfile = getDeviceProfile(shortServerId, shortServerIdBs); validator.validateDataImpl(tenantId, deviceProfile); @@ -132,8 +135,13 @@ class DeviceProfileDataValidatorTest { } @Test - void testValidateDeviceProfile_Lwm2mShortServerId_Ok_BootstrapShortServerId_null_Error() { - verifyValidationError(123, null, "Bootstrap" + msgErrorNotNull); + void testValidateDeviceProfile_Lwm2mShortServerId_Ok_BootstrapShortServerId_validate_0_to_null_Ok() { + Integer shortServerId = 123; + Integer shortServerIdBs = 0; + DeviceProfile deviceProfile = getDeviceProfile(shortServerId, shortServerIdBs); + + validator.validateDataImpl(tenantId, deviceProfile); + verify(validator).validateString("Device profile name", deviceProfile.getName()); } @Test @@ -153,7 +161,7 @@ class DeviceProfileDataValidatorTest { @Test void testValidateDeviceProfile_Lwm2mShortServerId_More_65534_Error_BootstrapShortServerId_Ok() { - verifyValidationError(65535, 111, msgErrorLwm2mRange); + verifyValidationError(NOT_USED_IDENTIFYING_LWM2M_SERVER_MAX.getId(), 111, msgErrorLwm2mRange); } @Test diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java index f5aa8a3af5..59c0db9eee 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java @@ -24,6 +24,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMinutesAlwaysExistsTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMinutesAlwaysExistsTest.java index cc76484d0b..8ec8c493af 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMinutesAlwaysExistsTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMinutesAlwaysExistsTest.java @@ -117,4 +117,16 @@ public class CassandraBaseTimeseriesDaoPartitioningMinutesAlwaysExistsTest { ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:10:00Z").getTime())); } + @Test + public void testEstimatePartitionCount() throws ParseException { + assertThat(tsDao.estimatePartitionCount(0, Long.MAX_VALUE)).as("centuries").isEqualTo(153_722_867_280_914L); + assertThat(tsDao.estimatePartitionCount(0, 0)).as("single").isEqualTo(1L); + long startTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2019-12-12T00:00:00Z").getTime()); + long endTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2021-01-31T23:59:59Z").getTime()); + assertThat(tsDao.estimatePartitionCount(startTs, endTs)).as("600,479 minutes + 2 spare periods").isEqualTo(600479 + 2); + assertThat(tsDao.estimatePartitionCount(endTs, startTs)).as("wrong period estimated as 1").isEqualTo(1L); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest.java index eb60000404..65ed8019c0 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest.java @@ -15,25 +15,35 @@ */ package org.thingsboard.server.dao.timeseries; +import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Answers; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.context.junit4.SpringRunner; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.TsKvQuery; import org.thingsboard.server.dao.cassandra.CassandraCluster; import org.thingsboard.server.dao.nosql.CassandraBufferedRateReadExecutor; import org.thingsboard.server.dao.nosql.CassandraBufferedRateWriteExecutor; import java.text.ParseException; import java.util.List; +import java.util.UUID; import static org.apache.commons.lang3.time.DateFormatUtils.ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.willReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; @RunWith(SpringRunner.class) @SpringBootTest(classes = CassandraBaseTimeseriesDao.class) @@ -50,7 +60,7 @@ import static org.assertj.core.api.Assertions.assertThat; @Slf4j public class CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest { - @Autowired + @MockitoSpyBean CassandraBaseTimeseriesDao tsDao; @MockBean(answer = Answers.RETURNS_MOCKS) @@ -131,4 +141,53 @@ public class CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest { ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2021-01-01T00:00:00Z").getTime())); } + @Test + public void testEstimatePartitionCount() throws ParseException { + assertThat(tsDao.estimatePartitionCount(0, Long.MAX_VALUE)).as("centuries").isEqualTo(3_507_324_297L); + assertThat(tsDao.estimatePartitionCount(0, 0)).as("single").isEqualTo(1L); + long startTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2019-12-12T00:00:00Z").getTime()); + long endTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2021-01-31T23:59:59Z").getTime()); + assertThat(tsDao.estimatePartitionCount(startTs, endTs)).as("13 month + 2 spare periods").isEqualTo(13 + 2); + assertThat(tsDao.estimatePartitionCount(endTs, startTs)).as("wrong period estimated as 1").isEqualTo(1L); + } + + @Test + public void testGetPartitionsFutureModeratePartitionsCount() throws ParseException { + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + TsKvQuery query = mock(TsKvQuery.class); + long startTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2019-12-12T00:00:00Z").getTime()); + long endTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2021-01-31T23:59:59Z").getTime()); + + willReturn(mock(ListenableFuture.class)).given(tsDao).getPartitionsFromDB(tenantId, query, tenantId, startTs, endTs); + + tsDao.getPartitionsFuture(tenantId, query, tenantId, startTs, endTs); + + verify(tsDao).estimatePartitionCount(startTs, endTs); + verify(tsDao).calculatePartitions(eq(startTs), eq(endTs), anyInt()); + verify(tsDao, never()).getPartitionsFromDB(tenantId, query, tenantId, startTs, endTs); + } + + @Test + public void testGetPartitionsFutureHugePartitionsCountPreventOOMFallbackToDB() throws ParseException { + + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + TsKvQuery query = mock(TsKvQuery.class); + long startTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2000-12-12T00:00:00Z").getTime()); + long endTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("3000-01-31T23:59:59Z").getTime()); + + willReturn(mock(ListenableFuture.class)).given(tsDao).getPartitionsFromDB(tenantId, query, tenantId, startTs, endTs); + + tsDao.getPartitionsFuture(tenantId, query, tenantId, startTs, endTs); + + verify(tsDao).estimatePartitionCount(startTs, endTs); + verify(tsDao, never()).calculatePartitions(eq(startTs), eq(endTs), anyInt()); + verify(tsDao).getPartitionsFromDB(tenantId, query, tenantId, startTs, endTs); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningYearsAlwaysExistsTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningYearsAlwaysExistsTest.java index 0425b23a1e..aa3e48c73f 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningYearsAlwaysExistsTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningYearsAlwaysExistsTest.java @@ -112,4 +112,16 @@ public class CassandraBaseTimeseriesDaoPartitioningYearsAlwaysExistsTest { ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2025-01-01T00:00:00Z").getTime())); } + @Test + public void testEstimatePartitionCount() throws ParseException { + assertThat(tsDao.estimatePartitionCount(0, Long.MAX_VALUE)).as("centuries").isEqualTo(292_277_026L); + assertThat(tsDao.estimatePartitionCount(0, 0)).as("single").isEqualTo(1L); + long startTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2019-12-12T00:00:00Z").getTime()); + long endTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2021-01-31T23:59:59Z").getTime()); + assertThat(tsDao.estimatePartitionCount(startTs, endTs)).as("2 years + 2 spare periods").isEqualTo(2 + 2); + assertThat(tsDao.estimatePartitionCount(endTs, startTs)).as("wrong period estimated as 1").isEqualTo(1L); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/NoSqlTsPartitionDateTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/NoSqlTsPartitionDateTest.java new file mode 100644 index 0000000000..f860d6ad51 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/NoSqlTsPartitionDateTest.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.timeseries; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class NoSqlTsPartitionDateTest { + + @ParameterizedTest + @EnumSource(NoSqlTsPartitionDate.class) + void getDurationMsTest(NoSqlTsPartitionDate tsPartitionDate) throws Exception { + final Long durationMs = switch (tsPartitionDate) { + case MINUTES -> 60000L; + case HOURS -> 3600000L; + case DAYS -> 86400000L; + case MONTHS -> 2629746000L; + case YEARS -> 31556952000L; + case INDEFINITE -> Long.MAX_VALUE; + default -> null; //should be here in case a new enum value will be added in future + }; + assertThat(durationMs).isNotNull(); + assertThat(tsPartitionDate.getDurationMs()).isEqualTo(durationMs); + } + +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java index 0a15d6e8aa..7a4d3f1cfe 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java @@ -266,6 +266,33 @@ public class TestRestClient { .as(ArrayNode.class); } + + public ValidatableResponse deleteEntityAttributes(EntityId entityId, AttributeScope scope, String keys) { + Map pathParams = new HashMap<>(); + pathParams.put("entityId", entityId.getId().toString()); + pathParams.put("entityType", entityId.getEntityType().name()); + pathParams.put("scope", scope.name()); + return given().spec(requestSpec) + .pathParams(pathParams) + .queryParam("keys", keys) + .delete("/api/plugins/telemetry/{entityType}/{entityId}/{scope}") + .then() + .statusCode(HTTP_OK); + } + + public ValidatableResponse deleteEntityTimeseries(EntityId entityId, String keys, boolean deleteAllDataForKeys) { + Map pathParams = new HashMap<>(); + pathParams.put("entityType", entityId.getEntityType().name()); + pathParams.put("entityId", entityId.getId().toString()); + return given().spec(requestSpec) + .pathParams(pathParams) + .queryParam("keys", keys) + .queryParam("deleteAllDataForKeys", Boolean.toString(deleteAllDataForKeys)) + .delete("/api/plugins/telemetry/{entityType}/{entityId}/timeseries/delete") + .then() + .statusCode(HTTP_OK); + } + public JsonNode getLatestTelemetry(EntityId entityId) { return given().spec(requestSpec) .get("/api/plugins/telemetry/" + entityId.getEntityType().name() + "/" + entityId.getId() + "/values/timeseries") @@ -378,6 +405,23 @@ public class TestRestClient { .as(EntityRelation.class); } + + public EntityRelation deleteEntityRelation(EntityId fromId, String relationType, EntityId toId) { + Map queryParams = new HashMap<>(); + queryParams.put("fromId", fromId.getId().toString()); + queryParams.put("fromType", fromId.getEntityType().name()); + queryParams.put("relationType", relationType); + queryParams.put("toId", toId.getId().toString()); + queryParams.put("toType", toId.getEntityType().name()); + return given().spec(requestSpec) + .queryParams(queryParams) + .delete("/api/v2/relation") + .then() + .statusCode(HTTP_OK) + .extract() + .as(EntityRelation.class); + } + public JsonNode postServerSideRpc(DeviceId deviceId, JsonNode serverRpcPayload) { return given().spec(requestSpec) .body(serverRpcPayload) diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java index 7f2dfba937..9c043eee8d 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java @@ -17,12 +17,13 @@ package org.thingsboard.server.msa.cf; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; -import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.RandomStringUtils; import org.testng.annotations.AfterClass; import org.testng.annotations.BeforeClass; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.asset.Asset; @@ -32,8 +33,9 @@ import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; -import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; @@ -50,10 +52,12 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.msa.AbstractContainerTest; import org.thingsboard.server.msa.ui.utils.EntityPrototypes; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -366,19 +370,13 @@ public class CalculatedFieldTest extends AbstractContainerTest { // Dynamic groups via relations ZoneGroupConfiguration allowedZoneGroupConfiguration = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - var allowedDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - allowedDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM); - allowedDynamicSourceConfiguration.setMaxLevel(1); - allowedDynamicSourceConfiguration.setFetchLastLevelOnly(true); - allowedDynamicSourceConfiguration.setRelationType("AllowedZone"); + var allowedDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + allowedDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "AllowedZone"))); allowedZoneGroupConfiguration.setRefDynamicSourceConfiguration(allowedDynamicSourceConfiguration); ZoneGroupConfiguration restrictedZoneGroupConfiguration = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - var restrictedDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - restrictedDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM); - restrictedDynamicSourceConfiguration.setMaxLevel(1); - restrictedDynamicSourceConfiguration.setFetchLastLevelOnly(true); - restrictedDynamicSourceConfiguration.setRelationType("RestrictedZone"); + var restrictedDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + restrictedDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "RestrictedZone"))); restrictedZoneGroupConfiguration.setRefDynamicSourceConfiguration(restrictedDynamicSourceConfiguration); cfg.setZoneGroups(Map.of("allowedZones", allowedZoneGroupConfiguration, "restrictedZones", restrictedZoneGroupConfiguration)); @@ -423,6 +421,177 @@ public class CalculatedFieldTest extends AbstractContainerTest { testRestClient.deleteCalculatedFieldIfExists(saved.getId()); } + @Test + public void testPropagationCalculatedField_withExpression() { + // login tenant admin + testRestClient.getAndSetUserToken(tenantAdminId); + + // --- Arrange entities --- + String deviceToken = "propagationDeviceTokenA"; + Device device = testRestClient.postDevice(deviceToken, createDevice("Propagation Device With Expression", deviceProfileId)); + Asset asset1 = testRestClient.postAsset(createAsset("Propagated Asset 1", null)); + Asset asset2 = testRestClient.postAsset(createAsset("Propagated Asset 2", null)); + + // Create relations FROM assets TO device + EntityRelation rel1 = new EntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + testRestClient.postEntityRelation(rel1); + testRestClient.postEntityRelation(rel2); + + // Telemetry on device + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":12.5}")); + + // --- Build CF: PROPAGATION with expression --- + CalculatedField cf = new CalculatedField(); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.PROPAGATION); + cf.setName("Propagation CF (expr)"); + cf.setConfigurationVersion(1); + + PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.TO, EntityRelation.CONTAINS_TYPE)); + cfg.setApplyExpressionToResolvedArguments(true); + + Argument arg = new Argument(); + arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("t", arg)); + + cfg.setExpression("{\"testResult\": t * 2}"); + + Output output = new Output(); + output.setType(OutputType.ATTRIBUTES); + output.setScope(AttributeScope.SERVER_SCOPE); + cfg.setOutput(output); + + cf.setConfiguration(cfg); + + CalculatedField saved = testRestClient.postCalculatedField(cf); + + // --- Assert propagated calculation (expression applied) --- + await().alias("propagation expr mode evaluation") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs1 = testRestClient.getAttributes(asset1.getId(), SERVER_SCOPE, "testResult"); + assertThat(attrs1).isNotNull().hasSize(1); + Map m1 = intKv(attrs1); + assertThat(m1).containsEntry("testResult", 25); + + ArrayNode attrs2 = testRestClient.getAttributes(asset2.getId(), SERVER_SCOPE, "testResult"); + assertThat(attrs2).isNotNull().hasSize(1); + Map m2 = intKv(attrs2); + assertThat(m2).containsEntry("testResult", 25); + }); + + testRestClient.deleteEntityRelation(asset1.getId(), EntityRelation.CONTAINS_TYPE, device.getId()); + testRestClient.deleteEntityAttributes(asset1.getId(), SERVER_SCOPE, "testResult"); + + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}")); + + // --- Assert propagated calculation (expression applied with new temperature argument and one relation removed) --- + await().alias("propagation expr mode evaluation after temperature update") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs1 = testRestClient.getAttributes(asset1.getId(), SERVER_SCOPE, "testResult"); + assertThat(attrs1).isNullOrEmpty(); + + ArrayNode attrs2 = testRestClient.getAttributes(asset2.getId(), SERVER_SCOPE, "testResult"); + assertThat(attrs2).isNotNull().hasSize(1); + Map m2 = intKv(attrs2); + assertThat(m2).containsEntry("testResult", 50); + }); + + testRestClient.deleteCalculatedFieldIfExists(saved.getId()); + } + + @Test + public void testPropagationCalculatedField_withoutExpression() { + // login tenant admin + testRestClient.getAndSetUserToken(tenantAdminId); + + // --- Arrange entities --- + String deviceToken = "propagationDeviceTokenB"; + Device device = testRestClient.postDevice(deviceToken, createDevice("Propagation Device Without Expression", deviceProfileId)); + Asset asset1 = testRestClient.postAsset(createAsset("Propagated Asset 3", null)); + Asset asset2 = testRestClient.postAsset(createAsset("Propagated Asset 4", null)); + + // Create relations FROM assets TO device + EntityRelation rel1 = new EntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + testRestClient.postEntityRelation(rel1); + testRestClient.postEntityRelation(rel2); + + // Telemetry on device + long ts = System.currentTimeMillis() - 300000L; + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":12.5}}", ts))); + + // --- Build CF: PROPAGATION without expression --- + CalculatedField cf = new CalculatedField(); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.PROPAGATION); + cf.setName("Propagation CF (args-only)"); + cf.setConfigurationVersion(1); + + PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.TO, EntityRelation.CONTAINS_TYPE)); + cfg.setApplyExpressionToResolvedArguments(false); // arguments-only mode + + Argument arg = new Argument(); + arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("temperatureComputed", arg)); + + Output output = new Output(); + output.setType(OutputType.TIME_SERIES); + cfg.setOutput(output); + + cf.setConfiguration(cfg); + + CalculatedField saved = testRestClient.postCalculatedField(cf); + + // --- Assert propagated calculation (arguments-only mode) --- + await().alias("propagation args-only evaluation") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode temperature1 = testRestClient.getLatestTelemetry(asset1.getId()); + assertThat(temperature1).isNotNull(); + assertThat(temperature1.get("temperatureComputed")).isNotNull(); + assertThat(temperature1.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(temperature1.get("temperatureComputed").get(0).get("value").asText()).isEqualTo("12.5"); + + JsonNode temperature2 = testRestClient.getLatestTelemetry(asset2.getId()); + assertThat(temperature2).isNotNull(); + assertThat(temperature2.get("temperatureComputed")).isNotNull(); + assertThat(temperature2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(temperature2.get("temperatureComputed").get(0).get("value").asText()).isEqualTo("12.5"); + }); + + testRestClient.deleteEntityRelation(asset1.getId(), EntityRelation.CONTAINS_TYPE, device.getId()); + testRestClient.deleteEntityTimeseries(asset1.getId(), "temperatureComputed", true); + + // Update telemetry on device + long newTs = System.currentTimeMillis() - 300000L; + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":25}}", newTs))); + + // --- Assert propagated calculation (arguments-only mode after update) --- + await().alias("propagation args-only evaluation after temperature update") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode temperature1 = testRestClient.getLatestTelemetry(asset1.getId()); + assertThat(temperature1).isNullOrEmpty(); + + JsonNode temperature2 = testRestClient.getLatestTelemetry(asset2.getId()); + assertThat(temperature2).isNotNull(); + assertThat(temperature2.get("temperatureComputed")).isNotNull(); + assertThat(temperature2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs)); + assertThat(temperature2.get("temperatureComputed").get(0).get("value").asInt()).isEqualTo(25); + }); + + testRestClient.deleteCalculatedFieldIfExists(saved.getId()); + } + private CalculatedField createSimpleCalculatedField() { return createSimpleCalculatedField(device.getId()); } @@ -431,7 +600,7 @@ public class CalculatedFieldTest extends AbstractContainerTest { CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(entityId); calculatedField.setType(CalculatedFieldType.SIMPLE); - calculatedField.setName("C to F" + RandomStringUtils.randomAlphabetic(5)); + calculatedField.setName("C to F" + RandomStringUtils.insecure().nextAlphabetic(5)); calculatedField.setDebugSettings(DebugSettings.all()); SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); @@ -455,15 +624,11 @@ public class CalculatedFieldTest extends AbstractContainerTest { return testRestClient.postCalculatedField(calculatedField); } - private CalculatedField createScriptCalculatedField() { - return createScriptCalculatedField(device.getId(), asset.getId()); - } - private CalculatedField createScriptCalculatedField(EntityId entityId, EntityId refEntityId) { CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(entityId); calculatedField.setType(CalculatedFieldType.SCRIPT); - calculatedField.setName("Air density" + RandomStringUtils.randomAlphabetic(5)); + calculatedField.setName("Air density" + RandomStringUtils.insecure().nextAlphabetic(5)); calculatedField.setDebugSettings(DebugSettings.all()); ScriptCalculatedFieldConfiguration config = new ScriptCalculatedFieldConfiguration(); @@ -518,4 +683,12 @@ public class CalculatedFieldTest extends AbstractContainerTest { return m; } + private static Map intKv(ArrayNode attrs) { + Map m = new HashMap<>(); + for (JsonNode n : attrs) { + m.put(n.get("key").asText(), n.get("value").asInt()); + } + return m; + } + } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/CoapClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/CoapClientTest.java index 995b90e529..e2c4b25809 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/CoapClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/CoapClientTest.java @@ -21,6 +21,7 @@ import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileProvisionType; @@ -28,6 +29,8 @@ import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.msa.AbstractCoapClientTest; import org.thingsboard.server.msa.DisableUIListeners; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevicePrototype; @@ -52,14 +55,27 @@ public class CoapClientTest extends AbstractCoapClientTest{ DeviceProfile deviceProfile = testRestClient.getDeviceProfileById(device.getDeviceProfileId()); deviceProfile = updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES); - DeviceCredentials expectedDeviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); + DeviceCredentials deviceCreds = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); JsonNode provisionResponse = JacksonUtil.fromBytes(createCoapClientAndPublish(device.getName())); - assertThat(provisionResponse.get("credentialsType").asText()).isEqualTo(expectedDeviceCredentials.getCredentialsType().name()); - assertThat(provisionResponse.get("credentialsValue").asText()).isEqualTo(expectedDeviceCredentials.getCredentialsId()); + assertThat(provisionResponse.get("credentialsType").asText()).isEqualTo(deviceCreds.getCredentialsType().name()); + assertThat(provisionResponse.get("credentialsValue").asText()).isEqualTo(deviceCreds.getCredentialsId()); assertThat(provisionResponse.get("status").asText()).isEqualTo("SUCCESS"); + JsonNode attributes = testRestClient.getAttributes(device.getId(), AttributeScope.SERVER_SCOPE, "provisionState"); + assertThat(attributes.get(0).get("value").asText()).isEqualTo("provisioned"); + + // provision second time should fail + JsonNode provisionResponse2 = JacksonUtil.fromBytes(createCoapClientAndPublish(device.getName())); + assertThat(provisionResponse2.get("status").asText()).isEqualTo("FAILURE"); + + // update provision attribute to non-valid value + testRestClient.postTelemetryAttribute(device.getId(), AttributeScope.SERVER_SCOPE, JacksonUtil.valueToTree(Map.of("provisionState", "non-valid"))); + + JsonNode provisionResponse3 = JacksonUtil.fromBytes(createCoapClientAndPublish(device.getName())); + assertThat(provisionResponse3.get("status").asText()).isEqualTo("FAILURE"); + updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.DISABLED); } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java new file mode 100644 index 0000000000..636c80d5b3 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java @@ -0,0 +1,128 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa.connectivity; + +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; +import org.apache.hc.client5.http.ssl.HostnameVerificationPolicy; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.core5.ssl.SSLContexts; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; +import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.thingsboard.rest.client.RestClient; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.alarm.AlarmInfo; +import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.msa.AbstractContainerTest; +import org.thingsboard.server.msa.TestProperties; + +import javax.net.ssl.SSLContext; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevicePrototype; + +public class JavaRestClientTest extends AbstractContainerTest { + + private RestClient restClient; + + @BeforeClass + public void beforeClass() throws Exception { + SSLContext ssl = SSLContexts.custom() + .loadTrustMaterial((chain, authType) -> true) + .build(); + + var tls = new DefaultClientTlsStrategy( + ssl, + HostnameVerificationPolicy.CLIENT, + NoopHostnameVerifier.INSTANCE + ); + + HttpClientConnectionManager cm = PoolingHttpClientConnectionManagerBuilder.create() + .setTlsSocketStrategy(tls) + .build(); + + CloseableHttpClient httpClient = HttpClients.custom() + .setConnectionManager(cm) + .build(); + + RestTemplate rt = new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient)); + restClient = new RestClient(rt, TestProperties.getBaseUrl()); + } + + @BeforeMethod + public void setUp() throws Exception { + restClient.login("tenant@thingsboard.org", "tenant"); + } + + @AfterMethod + public void tearDown() { + } + + @Test + public void testGetAlarmsV2() { + Device device = restClient.saveDevice(defaultDevicePrototype(RandomStringUtils.randomAlphabetic(5))); + assertThat(device).isNotNull(); + + String type = "High temp" + RandomStringUtils.randomAlphabetic(5); + Alarm alarm = Alarm.builder() + .originator(device.getId()) + .severity(AlarmSeverity.CRITICAL) + .type(type) + .build(); + restClient.saveAlarm(alarm); + + // get /api/v2/alarm + PageData alarmsV2 = restClient.getAlarmsV2(device.getId(), null, null, List.of(type), null, new TimePageLink(10, 0)); + assertThat(alarmsV2.getData()).hasSize(1); + + PageData activeAlarms = restClient.getAlarmsV2(device.getId(), List.of(AlarmSearchStatus.ACTIVE), null, List.of(type), null, new TimePageLink(10, 0)); + assertThat(activeAlarms.getData()).hasSize(1); + + PageData cleared = restClient.getAlarmsV2(device.getId(), List.of(AlarmSearchStatus.CLEARED), null, List.of(type), null, new TimePageLink(10, 0)); + assertThat(cleared.getData()).hasSize(0); + + PageData activeAndClearedAlarms = restClient.getAlarmsV2(device.getId(), List.of(AlarmSearchStatus.CLEARED, AlarmSearchStatus.ACTIVE), null, null, null, new TimePageLink(10, 0)); + assertThat(activeAndClearedAlarms.getData()).hasSize(1); + + // get /api/v2/alarms + PageData allAlarmsV2 = restClient.getAllAlarmsV2(List.of(AlarmSearchStatus.ACTIVE), null, List.of(type), null, new TimePageLink(10, 0)); + assertThat(allAlarmsV2.getData()).hasSize(1); + + PageData allClearedAlarmsV2 = restClient.getAllAlarmsV2(List.of(AlarmSearchStatus.CLEARED), null, List.of(type), null, new TimePageLink(10, 0)); + assertThat(allClearedAlarmsV2.getData()).hasSize(0); + + // get /api/alarms + PageData allAlarms = restClient.getAllAlarms(AlarmSearchStatus.ACTIVE, null, new TimePageLink(10, 0), null); + assertThat(allAlarms.getData()).hasSize(1); + + PageData allClearedAlarms = restClient.getAllAlarms(AlarmSearchStatus.CLEARED, null, new TimePageLink(10, 0), null); + assertThat(allClearedAlarms.getData()).hasSize(0); + + } +} diff --git a/pom.xml b/pom.xml index fc3da6c968..b0768966bd 100755 --- a/pom.xml +++ b/pom.xml @@ -38,7 +38,7 @@ ${project.name} /var/log/${pkg.name} /usr/share/${pkg.name} - 3.4.8 + 3.4.10 2.4.0-b180830.0359 5.1.5 0.12.5 @@ -129,7 +129,7 @@ 1.20.6 1.0.2 1.12 - 5.8.0 + 6.1.0 2.27.0 2.12.0 @@ -146,7 +146,7 @@ 9.2.0 1.1.10.5 9.10.0 - 4.1.124.Final + 4.1.125.Final @@ -1929,9 +1929,6 @@ Typesafe Repository https://repo.typesafe.com/typesafe/releases/ - - sonatype - https://oss.sonatype.org/content/groups/public - + diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index 0e559efd46..875747195c 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -74,6 +74,7 @@ import org.thingsboard.server.common.data.UpdateMessage; import org.thingsboard.server.common.data.UsageInfo; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.UserEmailInfo; +import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmCommentInfo; @@ -98,6 +99,7 @@ import org.thingsboard.server.common.data.edge.EdgeInfo; import org.thingsboard.server.common.data.edge.EdgeInstructions; import org.thingsboard.server.common.data.edge.EdgeSearchQuery; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.AssetId; @@ -113,6 +115,8 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.MobileAppBundleId; import org.thingsboard.server.common.data.id.MobileAppId; +import org.thingsboard.server.common.data.id.NotificationId; +import org.thingsboard.server.common.data.id.NotificationRequestId; import org.thingsboard.server.common.data.id.OAuth2ClientId; import org.thingsboard.server.common.data.id.OAuth2ClientRegistrationTemplateId; import org.thingsboard.server.common.data.id.OtaPackageId; @@ -132,6 +136,13 @@ import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.mobile.app.MobileApp; import org.thingsboard.server.common.data.mobile.bundle.MobileAppBundle; import org.thingsboard.server.common.data.mobile.bundle.MobileAppBundleInfo; +import org.thingsboard.server.common.data.notification.Notification; +import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import org.thingsboard.server.common.data.notification.NotificationRequest; +import org.thingsboard.server.common.data.notification.NotificationRequestInfo; +import org.thingsboard.server.common.data.notification.NotificationRequestPreview; +import org.thingsboard.server.common.data.notification.settings.NotificationSettings; +import org.thingsboard.server.common.data.notification.settings.UserNotificationSettings; import org.thingsboard.server.common.data.oauth2.OAuth2Client; import org.thingsboard.server.common.data.oauth2.OAuth2ClientInfo; import org.thingsboard.server.common.data.oauth2.OAuth2ClientLoginInfo; @@ -509,6 +520,99 @@ public class RestClient implements Closeable { params).getBody(); } + public PageData getAllAlarms(AlarmSearchStatus searchStatus, AlarmStatus status, TimePageLink pageLink, Boolean fetchOriginator) { + String urlSecondPart = "/api/alarms?"; + Map params = new HashMap<>(); + if (fetchOriginator != null) { + params.put("fetchOriginator", String.valueOf(fetchOriginator)); + urlSecondPart += "&fetchOriginator={fetchOriginator}"; + } + if (searchStatus != null) { + params.put("searchStatus", searchStatus.name()); + urlSecondPart += "&searchStatus={searchStatus}"; + } + if (status != null) { + params.put("status", status.name()); + urlSecondPart += "&status={status}"; + } + + addTimePageLinkToParam(params, pageLink); + + return restTemplate.exchange( + baseURL + urlSecondPart + "&" + getTimeUrlParams(pageLink), + HttpMethod.GET, + HttpEntity.EMPTY, + new ParameterizedTypeReference>() { + }, + params).getBody(); + } + + public PageData getAlarmsV2(EntityId entityId, List statusList, List severityList, + List typeList, String assignedId, TimePageLink pageLink) { + String urlSecondPart = "/api/v2/alarm/{entityType}/{entityId}?"; + Map params = new HashMap<>(); + params.put("entityType", entityId.getEntityType().name()); + params.put("entityId", entityId.getId().toString()); + if (!CollectionUtils.isEmpty(statusList)) { + params.put("statusList", listEnumToString(statusList)); + urlSecondPart += "&statusList={statusList}"; + } + if (!CollectionUtils.isEmpty(severityList)) { + params.put("severityList", listEnumToString(severityList)); + urlSecondPart += "&severityList={severityList}"; + } + if (!CollectionUtils.isEmpty(typeList)) { + params.put("typeList", String.join(",", typeList)); + urlSecondPart += "&typeList={typeList}"; + } + if (assignedId != null) { + params.put("assignedId", assignedId); + urlSecondPart += "&assignedId={assignedId}"; + } + + addTimePageLinkToParam(params, pageLink); + + return restTemplate.exchange( + baseURL + urlSecondPart + "&" + getTimeUrlParams(pageLink), + HttpMethod.GET, + HttpEntity.EMPTY, + new ParameterizedTypeReference>() { + }, + params).getBody(); + } + + public PageData getAllAlarmsV2(List statusList, List severityList, + List typeList, String assignedId, TimePageLink pageLink) { + String urlSecondPart = "/api/v2/alarms?"; + Map params = new HashMap<>(); + if (!CollectionUtils.isEmpty(statusList)) { + params.put("statusList", listEnumToString(statusList)); + urlSecondPart += "&statusList={statusList}"; + } + if (!CollectionUtils.isEmpty(severityList)) { + params.put("severityList", listEnumToString(severityList)); + urlSecondPart += "&severityList={severityList}"; + } + if (!CollectionUtils.isEmpty(typeList)) { + params.put("typeList", String.join(",", typeList)); + urlSecondPart += "&typeList={typeList}"; + } + if (assignedId != null) { + params.put("assignedId", assignedId); + urlSecondPart += "&assignedId={assignedId}"; + } + + addTimePageLinkToParam(params, pageLink); + + return restTemplate.exchange( + baseURL + urlSecondPart + "&" + getTimeUrlParams(pageLink), + HttpMethod.GET, + HttpEntity.EMPTY, + new ParameterizedTypeReference>() { + }, + params).getBody(); + } + public Optional getHighestAlarmSeverity(EntityId entityId, AlarmSearchStatus searchStatus, AlarmStatus status) { Map params = new HashMap<>(); params.put("entityType", entityId.getEntityType().name()); @@ -1710,6 +1814,14 @@ public class RestClient implements Closeable { }).getBody(); } + public JsonNode findEntityTimeseriesAndAttributesKeysByQuery(EntityDataQuery query) { + return restTemplate.exchange( + baseURL + "/api/entitiesQuery/find/keys", + HttpMethod.POST, new HttpEntity<>(query), + new ParameterizedTypeReference() { + }).getBody(); + } + public PageData findAlarmDataByQuery(AlarmDataQuery query) { return restTemplate.exchange( baseURL + "/api/alarmsQuery/find", @@ -2158,9 +2270,11 @@ public class RestClient implements Closeable { restTemplate.delete(baseURL + "/api/oauth2/client/{id}", oAuth2ClientId.getId()); } - public PageData getTenantDomainInfos() { + public PageData getTenantDomainInfos(PageLink pageLink) { + Map params = new HashMap<>(); + addPageLinkToParam(params, pageLink); return restTemplate.exchange( - baseURL + "/api/domain/infos", + baseURL + "/api/domain/infos?" + getUrlParams(pageLink), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { @@ -2192,9 +2306,11 @@ public class RestClient implements Closeable { restTemplate.postForLocation(baseURL + "/api/domain/{id}/oauth2Clients", oauth2ClientIds, domainId.getId()); } - public PageData getTenantMobileApps() { + public PageData getTenantMobileApps(PageLink pageLink) { + Map params = new HashMap<>(); + addPageLinkToParam(params, pageLink); return restTemplate.exchange( - baseURL + "/api/mobile/app", + baseURL + "/api/mobile/app?" + getUrlParams(pageLink), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { @@ -2222,9 +2338,11 @@ public class RestClient implements Closeable { restTemplate.delete(baseURL + "/api/mobile/app/{id}", mobileAppId.getId()); } - public PageData getTenantMobileBundleInfos() { + public PageData getTenantMobileBundleInfos(PageLink pageLink) { + Map params = new HashMap<>(); + addPageLinkToParam(params, pageLink); return restTemplate.exchange( - baseURL + "/api/mobile/bundle/infos", + baseURL + "/api/mobile/bundle/infos?" + getUrlParams(pageLink), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { @@ -2846,6 +2964,17 @@ public class RestClient implements Closeable { }, params).getBody(); } + public PageData getUsersByQuery(PageLink pageLink) { + Map params = new HashMap<>(); + addPageLinkToParam(params, pageLink); + return restTemplate.exchange( + baseURL + "/api/users/info?" + getUrlParams(pageLink), + HttpMethod.GET, + HttpEntity.EMPTY, + new ParameterizedTypeReference>() { + }, params).getBody(); + } + public PageData getTenantAdmins(TenantId tenantId, PageLink pageLink) { Map params = new HashMap<>(); params.put("tenantId", tenantId.getId().toString()); @@ -3006,7 +3135,7 @@ public class RestClient implements Closeable { addWidgetInfoFiltersToParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList, params); return restTemplate.exchange( baseURL + "/api/widgetTypes?" + getUrlParams(pageLink) + - getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), + getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { @@ -3094,7 +3223,7 @@ public class RestClient implements Closeable { addWidgetInfoFiltersToParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList, params); return restTemplate.exchange( baseURL + "/api/widgetTypesInfos?widgetsBundleId={widgetsBundleId}&" + getUrlParams(pageLink) + - getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), + getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { @@ -4144,6 +4273,161 @@ public class RestClient implements Closeable { } } + public PageData getNotifications(PageLink pageLink) { + Map params = new HashMap<>(); + addPageLinkToParam(params, pageLink); + return restTemplate.exchange( + baseURL + "/api/notifications?" + getUrlParams(pageLink), + HttpMethod.GET, + HttpEntity.EMPTY, + new ParameterizedTypeReference>() { + }, params).getBody(); + } + + public Integer getUnreadNotificationsCount(NotificationDeliveryMethod deliveryMethod) { + String uri = "/api/notifications/unread/count?"; + Map params = new HashMap<>(); + if (deliveryMethod != null) { + params.put("deliveryMethod", deliveryMethod.name()); + uri += "&deliveryMethod={deliveryMethod}"; + } + return restTemplate.exchange( + baseURL + uri, + HttpMethod.GET, + HttpEntity.EMPTY, + Integer.class, params).getBody(); + } + + public void markNotificationAsRead(NotificationId notificationId) { + restTemplate.exchange( + baseURL + "/api/notification/{id}/read", + HttpMethod.PUT, + HttpEntity.EMPTY, + Void.class, + notificationId.getId()); + } + + public void markAllNotificationsAsRead(NotificationDeliveryMethod deliveryMethod) { + String uri = "/api/notifications/read?"; + Map params = new HashMap<>(); + if (deliveryMethod != null) { + params.put("deliveryMethod", deliveryMethod.name()); + uri += "&deliveryMethod={deliveryMethod}"; + } + restTemplate.exchange( + baseURL + uri, + HttpMethod.PUT, + HttpEntity.EMPTY, + Void.class); + } + + + public void deleteNotification(NotificationId notificationId) { + restTemplate.delete(baseURL + "/api/notification/{id}", notificationId.getId()); + } + + public NotificationRequest createNotificationRequest(NotificationRequest notificationRequest) { + return restTemplate.postForEntity(baseURL + "/api/notification/request", notificationRequest, NotificationRequest.class).getBody(); + } + + public NotificationRequestPreview getNotificationRequestPreview(NotificationRequest notificationRequest, int recipientsPreviewSize) { + return restTemplate.postForEntity(baseURL + "/api/notification/request/preview?recipientsPreviewSize={recipientsPreviewSize}", notificationRequest, NotificationRequestPreview.class, recipientsPreviewSize).getBody(); + } + + public Optional getNotificationRequestById(NotificationRequestId notificationRequestId) { + try { + ResponseEntity notificationRequest = restTemplate.getForEntity(baseURL + "/api/notification/request/{id}", NotificationRequestInfo.class, notificationRequestId.getId()); + return Optional.ofNullable(notificationRequest.getBody()); + } catch (HttpClientErrorException exception) { + if (exception.getStatusCode() == HttpStatus.NOT_FOUND) { + return Optional.empty(); + } else { + throw exception; + } + } + } + + public PageData getNotificationRequests(PageLink pageLink) { + Map params = new HashMap<>(); + addPageLinkToParam(params, pageLink); + return restTemplate.exchange( + baseURL + "/api/notification/requests?" + getUrlParams(pageLink), + HttpMethod.GET, + HttpEntity.EMPTY, + new ParameterizedTypeReference>() { + }, params).getBody(); + } + + public void deleteNotificationRequest(NotificationRequestId notificationRequestId) { + restTemplate.delete(baseURL + "/api/notification/request/{id}", notificationRequestId.getId()); + } + + public NotificationSettings saveNotificationSettings(NotificationSettings notificationSettings) { + return restTemplate.postForEntity(baseURL + "/api/notification/settings", notificationSettings, NotificationSettings.class).getBody(); + } + + public Optional getNotificationSettings() { + try { + ResponseEntity notificationSettings = restTemplate.getForEntity(baseURL + "/api/notification/settings", NotificationSettings.class); + return Optional.ofNullable(notificationSettings.getBody()); + } catch (HttpClientErrorException exception) { + if (exception.getStatusCode() == HttpStatus.NOT_FOUND) { + return Optional.empty(); + } else { + throw exception; + } + } + } + + public List getAvailableDeliveryMethods() { + return restTemplate.exchange(URI.create( + baseURL + "/api/notification/deliveryMethods"), + HttpMethod.GET, + HttpEntity.EMPTY, + new ParameterizedTypeReference>() { + }).getBody(); + } + + public UserNotificationSettings saveUserNotificationSettings(UserNotificationSettings userNotificationSettings) { + return restTemplate.postForEntity(baseURL + "/api/notification/settings/user", userNotificationSettings, UserNotificationSettings.class).getBody(); + } + + public Optional getUserNotificationSettings() { + try { + ResponseEntity userNotificationSettings = restTemplate.getForEntity(baseURL + "/api/notification/settings/user", UserNotificationSettings.class); + return Optional.ofNullable(userNotificationSettings.getBody()); + } catch (HttpClientErrorException exception) { + if (exception.getStatusCode() == HttpStatus.NOT_FOUND) { + return Optional.empty(); + } else { + throw exception; + } + } + } + + public AiModel saveAiModel(AiModel aiModel) { + return restTemplate.postForEntity(baseURL + "/api/ai/model", aiModel, AiModel.class).getBody(); + } + + public Optional getAiModel(AiModelId aiModelId) { + try { + ResponseEntity response = restTemplate.getForEntity( + baseURL + "/api/aiModel/{aiModelId}", AiModel.class, aiModelId.getId()); + return Optional.ofNullable(response.getBody()); + } catch (HttpClientErrorException exception) { + if (exception.getStatusCode() == HttpStatus.NOT_FOUND) { + return Optional.empty(); + } else { + throw exception; + } + } + } + + public void deleteAiModel(AiModelId aiModelId) { + restTemplate.delete(baseURL + "/api/aiModel/{aiModelId}", aiModelId.getId()); + } + + private String getTimeUrlParams(TimePageLink pageLink) { String urlParams = getUrlParams(pageLink); if (pageLink.getStartTime() != null) { diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAlarmService.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAlarmService.java index edcbffcfbc..0a1b80f3d4 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAlarmService.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAlarmService.java @@ -63,6 +63,8 @@ public interface RuleEngineAlarmService { AlarmApiCallResult clearAlarm(TenantId tenantId, AlarmId alarmId, long clearTs, JsonNode details); + AlarmApiCallResult clearAlarm(TenantId tenantId, AlarmId alarmId, long clearTs, JsonNode details, boolean pushEvent); + AlarmApiCallResult assignAlarm(TenantId tenantId, AlarmId alarmId, UserId assigneeId, long assignTs); AlarmApiCallResult unassignAlarm(TenantId tenantId, AlarmId alarmId, long assignTs); diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineTelemetryService.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineTelemetryService.java index 678f3a015d..27cf108d1f 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineTelemetryService.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineTelemetryService.java @@ -15,9 +15,6 @@ */ package org.thingsboard.rule.engine.api; -/** - * Created by ashvayka on 02.04.18. - */ public interface RuleEngineTelemetryService { void saveTimeseries(TimeseriesSaveRequest request); diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java index d2687a1b10..e703a7b256 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java @@ -24,6 +24,7 @@ import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.asset.Asset; @@ -32,6 +33,7 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.id.TenantId; @@ -78,6 +80,7 @@ import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.timeseries.TimeseriesService; @@ -252,6 +255,8 @@ public interface TbContext { void checkTenantEntity(EntityId entityId) throws TbNodeException; + & HasTenantId, I extends EntityId> void checkTenantOrSystemEntity(E entity) throws TbNodeException; + boolean isLocalEntity(EntityId entityId); RuleNodeId getSelfId(); @@ -308,6 +313,8 @@ public interface TbContext { ResourceService getResourceService(); + TbResourceDataCache getTbResourceDataCache(); + OtaPackageService getOtaPackageService(); RuleEngineDeviceProfileCache getDeviceProfileCache(); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAlarmResult.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAlarmResult.java index 6f5898fe64..d25846c984 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAlarmResult.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAlarmResult.java @@ -16,19 +16,27 @@ package org.thingsboard.rule.engine.action; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmApiCallResult; @Data @AllArgsConstructor +@NoArgsConstructor +@Builder public class TbAlarmResult { + boolean isCreated; boolean isUpdated; boolean isSeverityUpdated; boolean isCleared; Alarm alarm; + Long conditionRepeats; + Long conditionDuration; + public TbAlarmResult(boolean isCreated, boolean isUpdated, boolean isCleared, Alarm alarm) { this.isCreated = isCreated; this.isUpdated = isUpdated; @@ -38,11 +46,13 @@ public class TbAlarmResult { public static TbAlarmResult fromAlarmResult(AlarmApiCallResult result) { boolean isSeverityChanged = result.isSeverityChanged(); - return new TbAlarmResult( - result.isCreated(), - result.isModified() && !isSeverityChanged, - isSeverityChanged, - result.isCleared(), - result.getAlarm()); + return TbAlarmResult.builder() + .isCreated(result.isCreated()) + .isUpdated(result.isModified() && !isSeverityChanged) + .isSeverityUpdated(isSeverityChanged) + .isCleared(result.isCleared()) + .alarm(result.getAlarm()) + .build(); } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNode.java index 35c3ce7683..6f18e7c1dd 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.action; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -32,7 +31,6 @@ import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "assign to customer", @@ -42,7 +40,8 @@ import org.thingsboard.server.common.msg.TbMsg; "Rule node will create a new customer if it doesn't exist, and 'Create new customer if it doesn't exist' enabled.", configDirective = "tbActionNodeAssignToCustomerConfig", icon = "add_circle", - version = 1 + version = 1, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/assign-to-customer/" ) public class TbAssignToCustomerNode extends TbAbstractCustomerActionNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java index fc541ce4e4..bd7eca9d4f 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java @@ -45,7 +45,8 @@ import static com.google.common.util.concurrent.Futures.transformAsync; Message payload can be accessed via msg property. For example 'temperature = ' + msg.temperature ;. Message metadata can be accessed via metadata property. For example 'name = ' + metadata.customerName;.""", configDirective = "tbActionNodeClearAlarmConfig", - icon = "notifications_off" + icon = "notifications_off", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/clear-alarm/" ) public class TbClearAlarmNode extends TbAbstractAlarmNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java index 2d74929242..77c4c69c79 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java @@ -21,7 +21,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; import jakarta.annotation.Nullable; -import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.nullness.qual.NonNull; import org.thingsboard.common.util.DonAsynchron; import org.thingsboard.rule.engine.api.AttributesDeleteRequest; import org.thingsboard.rule.engine.api.AttributesSaveRequest; @@ -30,8 +30,6 @@ import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; -import org.thingsboard.rule.engine.api.TbNodeException; -import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DataConstants; @@ -53,7 +51,6 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.INACTIVITY_EVENT; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_REQUEST; import static org.thingsboard.server.common.data.msg.TbNodeConnectionType.SUCCESS; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "copy to view", @@ -63,16 +60,13 @@ import static org.thingsboard.server.common.data.msg.TbNodeConnectionType.SUCCES "Copy will be done only for attributes that are between start and end dates and according to attribute keys configuration. \n" + "Changes message originator to related entity view and produces new messages according to count of updated entity views", configDirective = "tbNodeEmptyConfig", - icon = "content_copy" + icon = "content_copy", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/copy-to-view/" ) public class TbCopyAttributesToEntityViewNode implements TbNode { - EmptyNodeConfiguration config; - @Override - public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, EmptyNodeConfiguration.class); - } + public void init(TbContext ctx, TbNodeConfiguration configuration) {} @Override public void onMsg(TbContext ctx, TbMsg msg) { @@ -138,14 +132,14 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { } private FutureCallback getFutureCallback(TbContext ctx, TbMsg msg, EntityView entityView) { - return new FutureCallback() { + return new FutureCallback<>() { @Override public void onSuccess(@Nullable Void result) { transformAndTellNext(ctx, msg, entityView); } @Override - public void onFailure(Throwable t) { + public void onFailure(@NonNull Throwable t) { ctx.tellFailure(msg, t); } }; @@ -157,18 +151,11 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { private boolean attributeContainsInEntityView(AttributeScope scope, String attrKey, EntityView entityView) { AttributesEntityView attributesEntityView = entityView.getKeys().getAttributes(); - List keys = null; - switch (scope) { - case CLIENT_SCOPE: - keys = attributesEntityView.getCs(); - break; - case SERVER_SCOPE: - keys = attributesEntityView.getSs(); - break; - case SHARED_SCOPE: - keys = attributesEntityView.getSh(); - break; - } + List keys = switch (scope) { + case CLIENT_SCOPE -> attributesEntityView.getCs(); + case SERVER_SCOPE -> attributesEntityView.getSs(); + case SHARED_SCOPE -> attributesEntityView.getSh(); + }; return CollectionsUtil.contains(keys, attrKey); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java index bd576be936..600c276901 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java @@ -19,7 +19,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.EnumUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; @@ -39,7 +38,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.io.IOException; import java.util.List; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "create alarm", relationTypes = {"Created", "Updated", "False"}, @@ -52,7 +50,8 @@ import java.util.List; "Message payload can be accessed via msg property. For example 'temperature = ' + msg.temperature ;. " + "Message metadata can be accessed via metadata property. For example 'name = ' + metadata.customerName;.", configDirective = "tbActionNodeCreateAlarmConfig", - icon = "notifications_active" + icon = "notifications_active", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/create-alarm/" ) public class TbCreateAlarmNode extends TbAbstractAlarmNode { @@ -62,10 +61,10 @@ public class TbCreateAlarmNode extends TbAbstractAlarmNodeSuccess - if the relation already exists or successfully created, otherwise Failure.", configDirective = "tbActionNodeCreateRelationConfig", icon = "add_circle", - version = 1 + version = 1, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/create-relation/" ) public class TbCreateRelationNode extends TbAbstractRelationActionNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNode.java index 82c3a0fab5..6a643aee75 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.action; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -32,8 +31,6 @@ import org.thingsboard.server.common.msg.TbMsg; import static org.thingsboard.common.util.DonAsynchron.withCallback; - -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "delete relation", @@ -55,7 +52,8 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; "Output connections: Success - If the relation(s) successfully deleted, otherwise Failure.", configDirective = "tbActionNodeDeleteRelationConfig", icon = "remove_circle", - version = 1 + version = 1, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/delete-relation/" ) public class TbDeleteRelationNode extends TbAbstractRelationActionNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeviceStateNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeviceStateNode.java index c9a50cf88d..caff3fc832 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeviceStateNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeviceStateNode.java @@ -58,7 +58,8 @@ import java.util.Set; "This node is particularly useful when device isn't using transports to receive data, such as when fetching data from external API or computing new data within the rule chain.", configClazz = TbDeviceStateNodeConfiguration.class, relationTypes = {TbNodeConnectionType.SUCCESS, TbNodeConnectionType.FAILURE, "Rate limited"}, - configDirective = "tbActionNodeDeviceStateConfig" + configDirective = "tbActionNodeDeviceStateConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/device-state/" ) public class TbDeviceStateNode implements TbNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java index 47ecac154f..d4a598cf1b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java @@ -19,6 +19,7 @@ import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.MoreExecutors; import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; @@ -44,19 +45,19 @@ import java.util.Objects; "Message payload can be accessed via msg property. For example 'temperature = ' + msg.temperature ;. " + "Message metadata can be accessed via metadata property. For example 'name = ' + metadata.customerName;.", configDirective = "tbActionNodeLogConfig", - icon = "menu" + icon = "menu", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/log/" ) public class TbLogNode implements TbNode { - private TbLogNodeConfiguration config; private ScriptEngine scriptEngine; private boolean standard; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbLogNodeConfiguration.class); - this.standard = isStandard(config); - this.scriptEngine = this.standard ? null : createScriptEngine(ctx, config); + var config = TbNodeUtils.convert(configuration, TbLogNodeConfiguration.class); + standard = isStandard(config); + scriptEngine = standard ? null : createScriptEngine(ctx, config); } ScriptEngine createScriptEngine(TbContext ctx, TbLogNodeConfiguration config) { @@ -75,7 +76,7 @@ public class TbLogNode implements TbNode { return; } - Futures.addCallback(scriptEngine.executeToStringAsync(msg), new FutureCallback() { + Futures.addCallback(scriptEngine.executeToStringAsync(msg), new FutureCallback<>() { @Override public void onSuccess(@Nullable String result) { log.info(result); @@ -83,7 +84,7 @@ public class TbLogNode implements TbNode { } @Override - public void onFailure(Throwable t) { + public void onFailure(@NonNull Throwable t) { ctx.tellFailure(msg, t); } }, MoreExecutors.directExecutor()); //usually js responses runs on js callback executor @@ -120,4 +121,5 @@ public class TbLogNode implements TbNode { scriptEngine.destroy(); } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbMsgCountNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbMsgCountNode.java index 3d93bea8a2..4488a95e31 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbMsgCountNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbMsgCountNode.java @@ -17,7 +17,6 @@ package org.thingsboard.rule.engine.action; import com.google.gson.Gson; import com.google.gson.JsonObject; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; @@ -34,7 +33,6 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "message count", @@ -42,7 +40,8 @@ import java.util.concurrent.atomic.AtomicLong; nodeDescription = "Count incoming messages", nodeDetails = "Count incoming messages for specified interval and produces POST_TELEMETRY_REQUEST msg with messages count", icon = "functions", - configDirective = "tbActionNodeMsgCountConfig" + configDirective = "tbActionNodeMsgCountConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/message-count/" ) public class TbMsgCountNode implements TbNode { @@ -59,7 +58,6 @@ public class TbMsgCountNode implements TbNode { this.delay = TimeUnit.SECONDS.toMillis(config.getInterval()); this.telemetryPrefix = config.getTelemetryPrefix(); scheduleTickMsg(ctx, null); - } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbSaveToCustomCassandraTableNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbSaveToCustomCassandraTableNode.java index e56a3459f8..6aa152403e 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbSaveToCustomCassandraTableNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbSaveToCustomCassandraTableNode.java @@ -16,21 +16,16 @@ package org.thingsboard.rule.engine.action; import com.datastax.oss.driver.api.core.ConsistencyLevel; -import com.datastax.oss.driver.api.core.cql.AsyncResultSet; import com.datastax.oss.driver.api.core.cql.BoundStatement; import com.datastax.oss.driver.api.core.cql.BoundStatementBuilder; import com.datastax.oss.driver.api.core.cql.PreparedStatement; import com.datastax.oss.driver.api.core.cql.Statement; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.google.common.base.Function; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; -import jakarta.annotation.Nullable; import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -50,14 +45,13 @@ import org.thingsboard.server.dao.nosql.TbResultSetFuture; import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; import static org.thingsboard.common.util.DonAsynchron.withCallback; @Slf4j -@RuleNode(type = ComponentType.ACTION, +@RuleNode( + type = ComponentType.ACTION, name = "save to custom table", configClazz = TbSaveToCustomCassandraTableNodeConfiguration.class, version = 1, @@ -71,7 +65,9 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; " otherwise, the message will be routed via success chain.", configDirective = "tbActionNodeCustomTableConfig", icon = "file_upload", - ruleChainTypes = RuleChainType.CORE) + ruleChainTypes = RuleChainType.CORE, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/save-to-custom-table/" +) public class TbSaveToCustomCassandraTableNode implements TbNode { private static final String TABLE_PREFIX = "cs_tb_"; @@ -82,7 +78,6 @@ public class TbSaveToCustomCassandraTableNode implements TbNode { private CassandraCluster cassandraCluster; private ConsistencyLevel defaultWriteLevel; private PreparedStatement saveStmt; - private ExecutorService readResultsProcessingExecutor; private Map fieldsMap; @Override @@ -95,31 +90,19 @@ public class TbSaveToCustomCassandraTableNode implements TbNode { if (!isTableExists()) { throw new TbNodeException("Table '" + TABLE_PREFIX + config.getTableName() + "' does not exist in Cassandra cluster."); } - startExecutor(); saveStmt = getSaveStmt(); } @Override public void onMsg(TbContext ctx, TbMsg msg) { - withCallback(save(msg, ctx), aVoid -> ctx.tellSuccess(msg), e -> ctx.tellFailure(msg, e), ctx.getDbCallbackExecutor()); + withCallback(save(msg, ctx), success -> ctx.tellSuccess(msg), e -> ctx.tellFailure(msg, e), ctx.getDbCallbackExecutor()); } @Override public void destroy() { - stopExecutor(); saveStmt = null; } - private void startExecutor() { - readResultsProcessingExecutor = Executors.newCachedThreadPool(); - } - - private void stopExecutor() { - if (readResultsProcessingExecutor != null) { - readResultsProcessingExecutor.shutdownNow(); - } - } - private boolean isTableExists() { var keyspaceMdOpt = getSession().getMetadata().getKeyspace(cassandraCluster.getKeyspaceName()); return keyspaceMdOpt.map(keyspaceMetadata -> @@ -180,7 +163,7 @@ public class TbSaveToCustomCassandraTableNode implements TbNode { return query.toString(); } - private ListenableFuture save(TbMsg msg, TbContext ctx) { + private TbResultSetFuture save(TbMsg msg, TbContext ctx) { JsonElement data = JsonParser.parseString(msg.getData()); if (!data.isJsonObject()) { throw new IllegalStateException("Invalid message structure, it is not a JSON Object: " + data); @@ -221,7 +204,7 @@ public class TbSaveToCustomCassandraTableNode implements TbNode { if (config.getDefaultTtl() > 0) { stmtBuilder.setInt(i.get(), config.getDefaultTtl()); } - return getFuture(executeAsyncWrite(ctx, stmtBuilder.build()), rs -> null); + return executeAsyncWrite(ctx, stmtBuilder.build()); } } @@ -251,16 +234,6 @@ public class TbSaveToCustomCassandraTableNode implements TbNode { } } - private ListenableFuture getFuture(TbResultSetFuture future, java.util.function.Function transformer) { - return Futures.transform(future, new Function() { - @Nullable - @Override - public T apply(@Nullable AsyncResultSet input) { - return transformer.apply(input); - } - }, readResultsProcessingExecutor); - } - @Override public TbPair upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException { boolean hasChanges = false; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbSaveToCustomCassandraTableNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbSaveToCustomCassandraTableNodeConfiguration.java index 48e0f6d679..7b71c8f705 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbSaveToCustomCassandraTableNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbSaveToCustomCassandraTableNodeConfiguration.java @@ -24,12 +24,10 @@ import java.util.Map; @Data public class TbSaveToCustomCassandraTableNodeConfiguration implements NodeConfiguration { - private String tableName; private Map fieldsMapping; private int defaultTtl; - @Override public TbSaveToCustomCassandraTableNodeConfiguration defaultConfiguration() { TbSaveToCustomCassandraTableNodeConfiguration configuration = new TbSaveToCustomCassandraTableNodeConfiguration(); @@ -40,4 +38,5 @@ public class TbSaveToCustomCassandraTableNodeConfiguration implements NodeConfig configuration.setFieldsMapping(map); return configuration; } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNode.java index e889e3a970..0ff79dee39 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNode.java @@ -44,7 +44,8 @@ import org.thingsboard.server.common.msg.TbMsg; "Other entities can be assigned only to one customer, so specified customer title in the configuration will be ignored if the originator isn't a dashboard.", configDirective = "tbActionNodeUnAssignToCustomerConfig", icon = "remove_circle", - version = 1 + version = 1, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/unassign-from-customer/" ) public class TbUnassignFromCustomerNode extends TbAbstractCustomerActionNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java index 3497795771..a9ebb7ecb7 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java @@ -18,12 +18,20 @@ package org.thingsboard.rule.engine.ai; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.Content; +import dev.langchain4j.data.message.ImageContent; +import dev.langchain4j.data.message.PdfFileContent; import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.TextContent; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.request.ResponseFormat; import dev.langchain4j.model.chat.response.ChatResponse; +import lombok.extern.slf4j.Slf4j; import org.checkerframework.checker.nullness.qual.NonNull; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; @@ -33,24 +41,38 @@ 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.rule.engine.external.TbAbstractExternalNode; +import org.thingsboard.server.common.data.GeneralFileDescriptor; +import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.TbResourceDataInfo; +import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.resource.TbResourceDataCache; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Base64; +import java.util.HashSet; import java.util.List; import java.util.NoSuchElementException; +import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.UUID; import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbResponseFormatType; import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; +@Slf4j @RuleNode( type = ComponentType.EXTERNAL, name = "AI request", @@ -70,13 +92,13 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie configClazz = TbAiNodeConfiguration.class, configDirective = "tbExternalNodeAiConfig", iconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDkiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OSA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zOC42MzExIDE3LjA3OTVDNDAuMTcwNSAxNy4wNzk2IDQxLjY1MTggMTcuNjg3MiA0Mi43NDc4IDE4Ljc3NjNDNDMuODQ0OCAxOS44NjYzIDQ0LjQ2NTkgMjEuMzUwMSA0NC40NjU5IDIyLjkwMjlWMzUuNDY1MkM0NC40NjU5IDM2LjM1MDkgNDQuMzU2NyAzNy4wNzY5IDQ0LjA5NzMgMzcuNzUxN0M0My44NDE0IDM4LjQxNjcgNDMuNDY1MSAzOC45NjE0IDQzLjA0NDggMzkuNTAyOEM0Mi40NjY3IDQwLjI0NzIgNDEuNjU2MyA0MC42ODU5IDQwLjg5MTkgNDAuOTM4OEM0MC4xMjExIDQxLjE5MzcgMzkuMzE0MyA0MS4yODg1IDM4LjYzMTEgNDEuMjg4NUgzMS4wMjU5TDIzLjM4MTIgNDUuODQ2NEMyMy4wNDMxIDQ2LjA0NzggMjIuNjI0MSA0Ni4wNTA3IDIyLjI4MzkgNDUuODUyOUMyMS45NDM3IDQ1LjY1NDcgMjEuNzMzOCA0NS4yODU5IDIxLjczMzcgNDQuODg3MlY0MS4yODg1SDE5LjY2NjNDMTguMTI2OSA0MS4yODg0IDE2LjY0NTUgNDAuNjgwOSAxNS41NDk2IDM5LjU5MThDMTQuNDUyNyAzOC41MDE5IDEzLjgzMTUgMzcuMDE3OSAxMy44MzE1IDM1LjQ2NTJWMjIuOTAyOUMxMy44MzE1IDIyLjMyMDIgMTMuOTE4NSAyMS43NDY4IDE0LjA4NTggMjEuMjAwN0wxNi4yODg5IDIxLjgxMDFMMTcuMjA5OSAyNS4yNTAyQzE3Ljk0MTYgMjcuOTg0NSAyMS43NTYyIDI3Ljk4NDQgMjIuNDg4IDI1LjI1MDJMMjMuNDA3OSAyMS44MTAxTDI2Ljc5MTcgMjAuODc0OUMyOC41NzkxIDIwLjM4MDUgMjkuMTc3IDE4LjUwMjYgMjguNTg4OCAxNy4wNzk1SDM4LjYzMTFaTTIyLjU4NDIgMzEuNTM5NUMyMS45OCAzMS41Mzk3IDIxLjQ5MDEgMzIuMDM3NiAyMS40OTAxIDMyLjY1MTlDMjEuNDkwMiAzMy4yNjYgMjEuOTgwMSAzMy43NjQgMjIuNTg0MiAzMy43NjQySDM0LjYxOTFDMzUuMjIzMyAzMy43NjQyIDM1LjcxMzEgMzMuMjY2MSAzNS43MTMyIDMyLjY1MTlDMzUuNzEzMiAzMi4wMzc1IDM1LjIyMzQgMzEuNTM5NSAzNC42MTkxIDMxLjUzOTVIMjIuNTg0MlpNMjQuNzcyMyAyNC44NjU3QzI0LjE2ODIgMjQuODY1OCAyMy42NzgzIDI1LjM2MzggMjMuNjc4MyAyNS45NzhDMjMuNjc4NCAyNi41OTIyIDI0LjE2ODMgMjcuMDkwMiAyNC43NzIzIDI3LjA5MDNIMzcuOTAxNEMzOC41MDU1IDI3LjA5MDMgMzguOTk1MyAyNi41OTIyIDM4Ljk5NTQgMjUuOTc4QzM4Ljk5NTQgMjUuMzYzNyAzOC41MDU2IDI0Ljg2NTcgMzcuOTAxNCAyNC44NjU3SDI0Ljc3MjNaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0xOC43ODkxIDExLjI5NzVDMTkuMDY5MSAxMC4xODA4IDIwLjYyOTkgMTAuMTgwOCAyMC45MDk5IDExLjI5NzVMMjEuOTE0MyAxNS4zMDM2QzIyLjAxMTYgMTUuNjkxOCAyMi4zMDY1IDE1Ljk5NzggMjIuNjg2NyAxNi4xMDNMMjYuMzYxMSAxNy4xMTg3QzI3LjQzNyAxNy40MTYyIDI3LjQzNyAxOC45Njc2IDI2LjM2MTEgMTkuMjY1MUwyMi42NzYxIDIwLjI4NEMyMi4zMDE4IDIwLjM4NzQgMjIuMDA4NyAyMC42ODQ1IDIxLjkwNjggMjEuMDY1TDIwLjkwNDYgMjQuODEyNUMyMC42MTE3IDI1LjkwNTggMTkuMDg2MSAyNS45MDU5IDE4Ljc5MzMgMjQuODEyNUwxNy43OTExIDIxLjA2NUMxNy42ODkzIDIwLjY4NDcgMTcuMzk3IDIwLjM4NzUgMTcuMDIyOSAyMC4yODRMMTMuMzM2OCAxOS4yNjUxQzEyLjI2MTQgMTguOTY3MyAxMi4yNjE1IDE3LjQxNjUgMTMuMzM2OCAxNy4xMTg3TDE3LjAxMTIgMTYuMTAzQzE3LjM5MTYgMTUuOTk3OCAxNy42ODc0IDE1LjY5MTkgMTcuNzg0NyAxNS4zMDM2TDE4Ljc4OTEgMTEuMjk3NVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTEwLjAzNDMgNy4wMjQyNUMxMC4zMDY4IDUuODk0NDQgMTEuODg2OCA1Ljg5NDQ0IDEyLjE1OTQgNy4wMjQyNUwxMi42OTg5IDkuMjYyOThDMTIuNzkyNyA5LjY1MTc0IDEzLjA4NTEgOS45NTg4NyAxMy40NjQgMTAuMDY3OUwxNS41NzczIDEwLjY3NTFDMTYuNjM5MyAxMC45ODAzIDE2LjYzOTMgMTIuNTEwOSAxNS41NzczIDEyLjgxNjFMMTMuNDUzMyAxMy40MjY1QzEzLjA4MDIgMTMuNTMzOCAxMi43OTA4IDEzLjgzMzkgMTIuNjkyNSAxNC4yMTUxTDEyLjE1NTEgMTYuMzA0QzExLjg3IDE3LjQxMTYgMTAuMzIzNiAxNy40MTE2IDEwLjAzODUgMTYuMzA0TDkuNTAwMDMgMTQuMjE1MUM5LjQwMTczIDEzLjgzMzkgOS4xMTIzNSAxMy41MzM3IDguNzM5MyAxMy40MjY1TDYuNjE1MjQgMTIuODE2MUM1LjU1Mzc4IDEyLjUxMDYgNS41NTM2NCAxMC45ODA0IDYuNjE1MjQgMTAuNjc1MUw4LjcyODYyIDEwLjA2NzlDOS4xMDc2IDkuOTU4OTggOS4zOTk3OCA5LjY1MTg0IDkuNDkzNjIgOS4yNjI5OEwxMC4wMzQzIDcuMDI0MjVaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0yNS45MDI4IDYuNzMzMTNDMjYuMTg3OCA1LjYyNTQxIDI3LjczNDMgNS42MjU0MSAyOC4wMTkzIDYuNzMzMTNMMjguMjAzMSA3LjQ0Njc5QzI4LjMwMyA3LjgzNDMxIDI4LjYwMDEgOC4xMzcwNSAyOC45ODA5IDguMjM5NzVMMjkuNTM0NCA4LjM4OTY1QzMwLjYxOTIgOC42ODIxMiAzMC42MTkzIDEwLjI0NjkgMjkuNTM0NCAxMC41MzkzTDI4Ljk2OTIgMTAuNjkxNEMyOC41OTQ0IDEwLjc5MjUgMjguMjk5OSAxMS4wODgzIDI4LjE5NTYgMTEuNDY4TDI4LjAxNTEgMTIuMTI4NUMyNy43MTc0IDEzLjIxMjggMjYuMjA0NyAxMy4yMTI4IDI1LjkwNyAxMi4xMjg1TDI1LjcyNTQgMTEuNDY4QzI1LjYyMTEgMTEuMDg4MiAyNS4zMjY4IDEwLjc5MjQgMjQuOTUxOCAxMC42OTE0TDI0LjM4NzcgMTAuNTM5M0MyMy4zMDI2IDEwLjI0NyAyMy4zMDI2IDguNjgxOTggMjQuMzg3NyA4LjM4OTY1TDI0Ljk0MDEgOC4yMzk3NUMyNS4zMjExIDguMTM3MDkgMjUuNjE5MSA3LjgzNDQ2IDI1LjcxOSA3LjQ0Njc5TDI1LjkwMjggNi43MzMxM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPC9zdmc+Cg==", - docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/external-nodes/#ai-request-node", - ruleChainTypes = RuleChainType.CORE + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/ai-request/" ) public final class TbAiNode extends TbAbstractExternalNode implements TbNode { private String systemPrompt; private String userPrompt; + private Set resourceIds; private ResponseFormat responseFormat; private int timeoutSeconds; private AiModelId modelId; @@ -111,6 +133,14 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { // LangChain4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT responseFormat = config.getResponseFormat().toLangChainResponseFormat(); } + if (config.getResourceIds() != null && !config.getResourceIds().isEmpty()) { + resourceIds = new HashSet<>(config.getResourceIds().size()); + for (UUID resourceId : config.getResourceIds()) { + TbResourceId tbResourceId = new TbResourceId(resourceId); + validateResource(ctx, tbResourceId); + resourceIds.add(tbResourceId); + } + } systemPrompt = config.getSystemPrompt(); userPrompt = config.getUserPrompt(); @@ -126,12 +156,42 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { @Override public void onMsg(TbContext ctx, TbMsg msg) { var ackedMsg = ackIfNeeded(ctx, msg); + final String processedUserPrompt = TbNodeUtils.processPattern(this.userPrompt, ackedMsg); + + final ListenableFuture userMessageFuture = + resourceIds == null + ? Futures.immediateFuture(UserMessage.from(processedUserPrompt)) + : Futures.transform( + loadResources(ctx), + resources -> UserMessage.from(buildContents(processedUserPrompt, resources)), + ctx.getDbCallbackExecutor() + ); + + Futures.addCallback( + userMessageFuture, + new FutureCallback<>() { + @Override + public void onSuccess(UserMessage userMessage) { + buildAndSendRequest(ctx, ackedMsg, userMessage); + } + @Override + public void onFailure(Throwable t) { + tellFailure(ctx, ackedMsg, t); + } + }, + MoreExecutors.directExecutor() + ); + } + + private void buildAndSendRequest(TbContext ctx, TbMsg ackedMsg, UserMessage userMessage) { List chatMessages = new ArrayList<>(2); - if (systemPrompt != null) { + + if (systemPrompt != null && !systemPrompt.isBlank()) { chatMessages.add(SystemMessage.from(TbNodeUtils.processPattern(systemPrompt, ackedMsg))); } - chatMessages.add(UserMessage.from(TbNodeUtils.processPattern(userPrompt, ackedMsg))); + + chatMessages.add(userMessage); var chatRequest = ChatRequest.builder() .messages(chatMessages) @@ -192,11 +252,67 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { return JacksonUtil.newObjectNode().put("response", response).toString(); } + private void validateResource(TbContext ctx, TbResourceId tbResourceId) throws TbNodeException { + TbResourceInfo resource = ctx.getResourceService().findResourceInfoById(ctx.getTenantId(), tbResourceId); + if (resource == null) { + throw new TbNodeException("[" + ctx.getTenantId() + "] Resource with ID: [" + tbResourceId + "] was not found", true); + } + if (!ResourceType.GENERAL.equals(resource.getResourceType())) { + throw new TbNodeException("[" + ctx.getTenantId() + "] Resource with ID: [" + tbResourceId + "] has unsupported resource type: " + resource.getResourceType(), true); + } + ctx.checkTenantOrSystemEntity(resource); + } + + private ListenableFuture> loadResources(TbContext ctx) { + final TenantId tenantId = ctx.getTenantId(); + final TbResourceDataCache cache = ctx.getTbResourceDataCache(); + List> futures = resourceIds.stream() + .map(id -> cache.getResourceDataInfoAsync(tenantId, id)) + .toList(); + return Futures.allAsList(futures); + } + + private List buildContents(String userPrompt, List resources) { + List contents = new ArrayList<>(1 + resources.size()); + contents.add(new TextContent(userPrompt)); // user prompt first + + resources.stream() + .filter(Objects::nonNull) + .map(this::toContent) + .forEach(contents::add); + + return contents; + } + + private Content toContent(TbResourceDataInfo resource) { + if (resource.getDescriptor() == null) { + throw new RuntimeException("Missing descriptor for resource"); + } + GeneralFileDescriptor descriptor = JacksonUtil.treeToValue(resource.getDescriptor(), GeneralFileDescriptor.class); + String mediaType = descriptor.getMediaType(); + if (mediaType == null) { + throw new RuntimeException("Missing mediaType in resource descriptor " + resource.getDescriptor()); + } + byte[] data = resource.getData(); + if (mediaType.startsWith("text/")) { + return new TextContent(new String(data, StandardCharsets.UTF_8)); + } + if (mediaType.equals("application/pdf")) { + return new PdfFileContent(Base64.getEncoder().encodeToString(data), mediaType); + } + if (mediaType.startsWith("image/")) { + return new ImageContent(Base64.getEncoder().encodeToString(data), mediaType); + } + log.debug("Trying to create text content for {}", resource.getDescriptor()); + return new TextContent(new String(data, StandardCharsets.UTF_8)); + } + @Override public void destroy() { super.destroy(); systemPrompt = null; userPrompt = null; + resourceIds = null; responseFormat = null; modelId = null; } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java index 48392aa76e..f51983ecb1 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java @@ -20,12 +20,14 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; import lombok.Data; import org.thingsboard.rule.engine.api.NodeConfiguration; import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.validation.Length; +import java.util.Set; +import java.util.UUID; + import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; @Data @@ -41,6 +43,8 @@ public class TbAiNodeConfiguration implements NodeConfiguration resourceIds; + @NotNull @Valid private TbResponseFormat responseFormat; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java index 5c891a9c74..5107e613a4 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java @@ -60,9 +60,7 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes @Override public ResponseFormat toLangChainResponseFormat() { - return ResponseFormat.builder() - .type(ResponseFormatType.TEXT) - .build(); + return ResponseFormat.TEXT; } } @@ -76,9 +74,7 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes @Override public ResponseFormat toLangChainResponseFormat() { - return ResponseFormat.builder() - .type(ResponseFormatType.JSON) - .build(); + return ResponseFormat.JSON; } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNode.java index 82aa667319..c4e448e2ee 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNode.java @@ -54,7 +54,8 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie "The node uses a pre-configured client and specified function to run.

" + "Output connections: Success, Failure.", configDirective = "tbExternalNodeLambdaConfig", - iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij48cGF0aCBkPSJNMTMuMjMgMTAuNTZWMTBjLTEuOTQgMC0zLjk5LjM5LTMuOTkgMi42NyAwIDEuMTYuNjEgMS45NSAxLjYzIDEuOTUuNzYgMCAxLjQzLS40NyAxLjg2LTEuMjIuNTItLjkzLjUtMS44LjUtMi44NG0yLjcgNi41M2MtLjE4LjE2LS40My4xNy0uNjMuMDYtLjg5LS43NC0xLjA1LTEuMDgtMS41NC0xLjc5LTEuNDcgMS41LTIuNTEgMS45NS00LjQyIDEuOTUtMi4yNSAwLTQuMDEtMS4zOS00LjAxLTQuMTcgMC0yLjE4IDEuMTctMy42NCAyLjg2LTQuMzggMS40Ni0uNjQgMy40OS0uNzYgNS4wNC0uOTNWNy41YzAtLjY2LjA1LTEuNDEtLjMzLTEuOTYtLjMyLS40OS0uOTUtLjctMS41LS43LTEuMDIgMC0xLjkzLjUzLTIuMTUgMS42MS0uMDUuMjQtLjI1LjQ4LS40Ny40OWwtMi42LS4yOGMtLjIyLS4wNS0uNDYtLjIyLS40LS41Ni42LTMuMTUgMy40NS00LjEgNi00LjEgMS4zIDAgMyAuMzUgNC4wMyAxLjMzQzE3LjExIDQuNTUgMTcgNi4xOCAxNyA3Ljk1djQuMTdjMCAxLjI1LjUgMS44MSAxIDIuNDguMTcuMjUuMjEuNTQgMCAuNzFsLTIuMDYgMS43OGgtLjAxIj48L3BhdGg+PHBhdGggZD0iTTIwLjE2IDE5LjU0QzE4IDIxLjE0IDE0LjgyIDIyIDEyLjEgMjJjLTMuODEgMC03LjI1LTEuNDEtOS44NS0zLjc2LS4yLS4xOC0uMDItLjQzLjI1LS4yOSAyLjc4IDEuNjMgNi4yNSAyLjYxIDkuODMgMi42MSAyLjQxIDAgNS4wNy0uNSA3LjUxLTEuNTMuMzctLjE2LjY2LjI0LjMyLjUxIj48L3BhdGg+PHBhdGggZD0iTTIxLjA3IDE4LjVjLS4yOC0uMzYtMS44NS0uMTctMi41Ny0uMDgtLjE5LjAyLS4yMi0uMTYtLjAzLS4zIDEuMjQtLjg4IDMuMjktLjYyIDMuNTMtLjMzLjI0LjMtLjA3IDIuMzUtMS4yNCAzLjMyLS4xOC4xNi0uMzUuMDctLjI2LS4xMS4yNi0uNjcuODUtMi4xNC41Ny0yLjV6Ij48L3BhdGg+PC9zdmc+" + iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij48cGF0aCBkPSJNMTMuMjMgMTAuNTZWMTBjLTEuOTQgMC0zLjk5LjM5LTMuOTkgMi42NyAwIDEuMTYuNjEgMS45NSAxLjYzIDEuOTUuNzYgMCAxLjQzLS40NyAxLjg2LTEuMjIuNTItLjkzLjUtMS44LjUtMi44NG0yLjcgNi41M2MtLjE4LjE2LS40My4xNy0uNjMuMDYtLjg5LS43NC0xLjA1LTEuMDgtMS41NC0xLjc5LTEuNDcgMS41LTIuNTEgMS45NS00LjQyIDEuOTUtMi4yNSAwLTQuMDEtMS4zOS00LjAxLTQuMTcgMC0yLjE4IDEuMTctMy42NCAyLjg2LTQuMzggMS40Ni0uNjQgMy40OS0uNzYgNS4wNC0uOTNWNy41YzAtLjY2LjA1LTEuNDEtLjMzLTEuOTYtLjMyLS40OS0uOTUtLjctMS41LS43LTEuMDIgMC0xLjkzLjUzLTIuMTUgMS42MS0uMDUuMjQtLjI1LjQ4LS40Ny40OWwtMi42LS4yOGMtLjIyLS4wNS0uNDYtLjIyLS40LS41Ni42LTMuMTUgMy40NS00LjEgNi00LjEgMS4zIDAgMyAuMzUgNC4wMyAxLjMzQzE3LjExIDQuNTUgMTcgNi4xOCAxNyA3Ljk1djQuMTdjMCAxLjI1LjUgMS44MSAxIDIuNDguMTcuMjUuMjEuNTQgMCAuNzFsLTIuMDYgMS43OGgtLjAxIj48L3BhdGg+PHBhdGggZD0iTTIwLjE2IDE5LjU0QzE4IDIxLjE0IDE0LjgyIDIyIDEyLjEgMjJjLTMuODEgMC03LjI1LTEuNDEtOS44NS0zLjc2LS4yLS4xOC0uMDItLjQzLjI1LS4yOSAyLjc4IDEuNjMgNi4yNSAyLjYxIDkuODMgMi42MSAyLjQxIDAgNS4wNy0uNSA3LjUxLTEuNTMuMzctLjE2LjY2LjI0LjMyLjUxIj48L3BhdGg+PHBhdGggZD0iTTIxLjA3IDE4LjVjLS4yOC0uMzYtMS44NS0uMTctMi41Ny0uMDgtLjE5LjAyLS4yMi0uMTYtLjAzLS4zIDEuMjQtLjg4IDMuMjktLjYyIDMuNTMtLjMzLjI0LjMtLjA3IDIuMzUtMS4yNCAzLjMyLS4xOC4xNi0uMzUuMDctLjI2LS4xMS4yNi0uNjcuODUtMi4xNC41Ny0yLjV6Ij48L3BhdGg+PC9zdmc+", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/aws-lambda/" ) public class TbAwsLambdaNode extends TbAbstractExternalNode { @@ -156,4 +157,5 @@ public class TbAwsLambdaNode extends TbAbstractExternalNode { } } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sns/TbSnsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sns/TbSnsNode.java index a13478c717..1877aff733 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sns/TbSnsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sns/TbSnsNode.java @@ -47,7 +47,8 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; "(messageId, requestId) in the Message Metadata from the AWS SNS. " + "For example requestId field can be accessed with metadata.requestId.", configDirective = "tbExternalNodeSnsConfig", - iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij48cGF0aCBkPSJNMTMuMjMgMTAuNTZWMTBjLTEuOTQgMC0zLjk5LjM5LTMuOTkgMi42NyAwIDEuMTYuNjEgMS45NSAxLjYzIDEuOTUuNzYgMCAxLjQzLS40NyAxLjg2LTEuMjIuNTItLjkzLjUtMS44LjUtMi44NG0yLjcgNi41M2MtLjE4LjE2LS40My4xNy0uNjMuMDYtLjg5LS43NC0xLjA1LTEuMDgtMS41NC0xLjc5LTEuNDcgMS41LTIuNTEgMS45NS00LjQyIDEuOTUtMi4yNSAwLTQuMDEtMS4zOS00LjAxLTQuMTcgMC0yLjE4IDEuMTctMy42NCAyLjg2LTQuMzggMS40Ni0uNjQgMy40OS0uNzYgNS4wNC0uOTNWNy41YzAtLjY2LjA1LTEuNDEtLjMzLTEuOTYtLjMyLS40OS0uOTUtLjctMS41LS43LTEuMDIgMC0xLjkzLjUzLTIuMTUgMS42MS0uMDUuMjQtLjI1LjQ4LS40Ny40OWwtMi42LS4yOGMtLjIyLS4wNS0uNDYtLjIyLS40LS41Ni42LTMuMTUgMy40NS00LjEgNi00LjEgMS4zIDAgMyAuMzUgNC4wMyAxLjMzQzE3LjExIDQuNTUgMTcgNi4xOCAxNyA3Ljk1djQuMTdjMCAxLjI1LjUgMS44MSAxIDIuNDguMTcuMjUuMjEuNTQgMCAuNzFsLTIuMDYgMS43OGgtLjAxIj48L3BhdGg+PHBhdGggZD0iTTIwLjE2IDE5LjU0QzE4IDIxLjE0IDE0LjgyIDIyIDEyLjEgMjJjLTMuODEgMC03LjI1LTEuNDEtOS44NS0zLjc2LS4yLS4xOC0uMDItLjQzLjI1LS4yOSAyLjc4IDEuNjMgNi4yNSAyLjYxIDkuODMgMi42MSAyLjQxIDAgNS4wNy0uNSA3LjUxLTEuNTMuMzctLjE2LjY2LjI0LjMyLjUxIj48L3BhdGg+PHBhdGggZD0iTTIxLjA3IDE4LjVjLS4yOC0uMzYtMS44NS0uMTctMi41Ny0uMDgtLjE5LjAyLS4yMi0uMTYtLjAzLS4zIDEuMjQtLjg4IDMuMjktLjYyIDMuNTMtLjMzLjI0LjMtLjA3IDIuMzUtMS4yNCAzLjMyLS4xOC4xNi0uMzUuMDctLjI2LS4xMS4yNi0uNjcuODUtMi4xNC41Ny0yLjV6Ij48L3BhdGg+PC9zdmc+" + iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij48cGF0aCBkPSJNMTMuMjMgMTAuNTZWMTBjLTEuOTQgMC0zLjk5LjM5LTMuOTkgMi42NyAwIDEuMTYuNjEgMS45NSAxLjYzIDEuOTUuNzYgMCAxLjQzLS40NyAxLjg2LTEuMjIuNTItLjkzLjUtMS44LjUtMi44NG0yLjcgNi41M2MtLjE4LjE2LS40My4xNy0uNjMuMDYtLjg5LS43NC0xLjA1LTEuMDgtMS41NC0xLjc5LTEuNDcgMS41LTIuNTEgMS45NS00LjQyIDEuOTUtMi4yNSAwLTQuMDEtMS4zOS00LjAxLTQuMTcgMC0yLjE4IDEuMTctMy42NCAyLjg2LTQuMzggMS40Ni0uNjQgMy40OS0uNzYgNS4wNC0uOTNWNy41YzAtLjY2LjA1LTEuNDEtLjMzLTEuOTYtLjMyLS40OS0uOTUtLjctMS41LS43LTEuMDIgMC0xLjkzLjUzLTIuMTUgMS42MS0uMDUuMjQtLjI1LjQ4LS40Ny40OWwtMi42LS4yOGMtLjIyLS4wNS0uNDYtLjIyLS40LS41Ni42LTMuMTUgMy40NS00LjEgNi00LjEgMS4zIDAgMyAuMzUgNC4wMyAxLjMzQzE3LjExIDQuNTUgMTcgNi4xOCAxNyA3Ljk1djQuMTdjMCAxLjI1LjUgMS44MSAxIDIuNDguMTcuMjUuMjEuNTQgMCAuNzFsLTIuMDYgMS43OGgtLjAxIj48L3BhdGg+PHBhdGggZD0iTTIwLjE2IDE5LjU0QzE4IDIxLjE0IDE0LjgyIDIyIDEyLjEgMjJjLTMuODEgMC03LjI1LTEuNDEtOS44NS0zLjc2LS4yLS4xOC0uMDItLjQzLjI1LS4yOSAyLjc4IDEuNjMgNi4yNSAyLjYxIDkuODMgMi42MSAyLjQxIDAgNS4wNy0uNSA3LjUxLTEuNTMuMzctLjE2LjY2LjI0LjMyLjUxIj48L3BhdGg+PHBhdGggZD0iTTIxLjA3IDE4LjVjLS4yOC0uMzYtMS44NS0uMTctMi41Ny0uMDgtLjE5LjAyLS4yMi0uMTYtLjAzLS4zIDEuMjQtLjg4IDMuMjktLjYyIDMuNTMtLjMzLjI0LjMtLjA3IDIuMzUtMS4yNCAzLjMyLS4xOC4xNi0uMzUuMDctLjI2LS4xMS4yNi0uNjcuODUtMi4xNC41Ny0yLjV6Ij48L3BhdGg+PC9zdmc+", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/aws-sns/" ) public class TbSnsNode extends TbAbstractExternalNode { @@ -125,4 +126,5 @@ public class TbSnsNode extends TbAbstractExternalNode { } } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sqs/TbSqsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sqs/TbSqsNode.java index 5d538df932..2183f20c1c 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sqs/TbSqsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sqs/TbSqsNode.java @@ -53,7 +53,8 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; ", sequenceNumber) in the Message Metadata from the AWS SQS." + " For example requestId field can be accessed with metadata.requestId.", configDirective = "tbExternalNodeSqsConfig", - iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij48cGF0aCBkPSJNMTMuMjMgMTAuNTZWMTBjLTEuOTQgMC0zLjk5LjM5LTMuOTkgMi42NyAwIDEuMTYuNjEgMS45NSAxLjYzIDEuOTUuNzYgMCAxLjQzLS40NyAxLjg2LTEuMjIuNTItLjkzLjUtMS44LjUtMi44NG0yLjcgNi41M2MtLjE4LjE2LS40My4xNy0uNjMuMDYtLjg5LS43NC0xLjA1LTEuMDgtMS41NC0xLjc5LTEuNDcgMS41LTIuNTEgMS45NS00LjQyIDEuOTUtMi4yNSAwLTQuMDEtMS4zOS00LjAxLTQuMTcgMC0yLjE4IDEuMTctMy42NCAyLjg2LTQuMzggMS40Ni0uNjQgMy40OS0uNzYgNS4wNC0uOTNWNy41YzAtLjY2LjA1LTEuNDEtLjMzLTEuOTYtLjMyLS40OS0uOTUtLjctMS41LS43LTEuMDIgMC0xLjkzLjUzLTIuMTUgMS42MS0uMDUuMjQtLjI1LjQ4LS40Ny40OWwtMi42LS4yOGMtLjIyLS4wNS0uNDYtLjIyLS40LS41Ni42LTMuMTUgMy40NS00LjEgNi00LjEgMS4zIDAgMyAuMzUgNC4wMyAxLjMzQzE3LjExIDQuNTUgMTcgNi4xOCAxNyA3Ljk1djQuMTdjMCAxLjI1LjUgMS44MSAxIDIuNDguMTcuMjUuMjEuNTQgMCAuNzFsLTIuMDYgMS43OGgtLjAxIj48L3BhdGg+PHBhdGggZD0iTTIwLjE2IDE5LjU0QzE4IDIxLjE0IDE0LjgyIDIyIDEyLjEgMjJjLTMuODEgMC03LjI1LTEuNDEtOS44NS0zLjc2LS4yLS4xOC0uMDItLjQzLjI1LS4yOSAyLjc4IDEuNjMgNi4yNSAyLjYxIDkuODMgMi42MSAyLjQxIDAgNS4wNy0uNSA3LjUxLTEuNTMuMzctLjE2LjY2LjI0LjMyLjUxIj48L3BhdGg+PHBhdGggZD0iTTIxLjA3IDE4LjVjLS4yOC0uMzYtMS44NS0uMTctMi41Ny0uMDgtLjE5LjAyLS4yMi0uMTYtLjAzLS4zIDEuMjQtLjg4IDMuMjktLjYyIDMuNTMtLjMzLjI0LjMtLjA3IDIuMzUtMS4yNCAzLjMyLS4xOC4xNi0uMzUuMDctLjI2LS4xMS4yNi0uNjcuODUtMi4xNC41Ny0yLjV6Ij48L3BhdGg+PC9zdmc+" + iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij48cGF0aCBkPSJNMTMuMjMgMTAuNTZWMTBjLTEuOTQgMC0zLjk5LjM5LTMuOTkgMi42NyAwIDEuMTYuNjEgMS45NSAxLjYzIDEuOTUuNzYgMCAxLjQzLS40NyAxLjg2LTEuMjIuNTItLjkzLjUtMS44LjUtMi44NG0yLjcgNi41M2MtLjE4LjE2LS40My4xNy0uNjMuMDYtLjg5LS43NC0xLjA1LTEuMDgtMS41NC0xLjc5LTEuNDcgMS41LTIuNTEgMS45NS00LjQyIDEuOTUtMi4yNSAwLTQuMDEtMS4zOS00LjAxLTQuMTcgMC0yLjE4IDEuMTctMy42NCAyLjg2LTQuMzggMS40Ni0uNjQgMy40OS0uNzYgNS4wNC0uOTNWNy41YzAtLjY2LjA1LTEuNDEtLjMzLTEuOTYtLjMyLS40OS0uOTUtLjctMS41LS43LTEuMDIgMC0xLjkzLjUzLTIuMTUgMS42MS0uMDUuMjQtLjI1LjQ4LS40Ny40OWwtMi42LS4yOGMtLjIyLS4wNS0uNDYtLjIyLS40LS41Ni42LTMuMTUgMy40NS00LjEgNi00LjEgMS4zIDAgMyAuMzUgNC4wMyAxLjMzQzE3LjExIDQuNTUgMTcgNi4xOCAxNyA3Ljk1djQuMTdjMCAxLjI1LjUgMS44MSAxIDIuNDguMTcuMjUuMjEuNTQgMCAuNzFsLTIuMDYgMS43OGgtLjAxIj48L3BhdGg+PHBhdGggZD0iTTIwLjE2IDE5LjU0QzE4IDIxLjE0IDE0LjgyIDIyIDEyLjEgMjJjLTMuODEgMC03LjI1LTEuNDEtOS44NS0zLjc2LS4yLS4xOC0uMDItLjQzLjI1LS4yOSAyLjc4IDEuNjMgNi4yNSAyLjYxIDkuODMgMi42MSAyLjQxIDAgNS4wNy0uNSA3LjUxLTEuNTMuMzctLjE2LjY2LjI0LjMyLjUxIj48L3BhdGg+PHBhdGggZD0iTTIxLjA3IDE4LjVjLS4yOC0uMzYtMS44NS0uMTctMi41Ny0uMDgtLjE5LjAyLS4yMi0uMTYtLjAzLS4zIDEuMjQtLjg4IDMuMjktLjYyIDMuNTMtLjMzLjI0LjMtLjA3IDIuMzUtMS4yNCAzLjMyLS4xOC4xNi0uMzUuMDctLjI2LS4xMS4yNi0uNjcuODUtMi4xNC41Ny0yLjV6Ij48L3BhdGg+PC9zdmc+", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/aws-sqs/" ) public class TbSqsNode extends TbAbstractExternalNode { @@ -156,4 +157,5 @@ public class TbSqsNode extends TbAbstractExternalNode { } } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java index 90ee0c9048..34514fd644 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java @@ -63,9 +63,9 @@ import static org.thingsboard.server.common.data.DataConstants.QUEUE_NAME; nodeDetails = "Generates messages with configurable period. Javascript function used for message generation.", inEnabled = false, configDirective = "tbActionNodeGeneratorConfig", - icon = "repeat" + icon = "repeat", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/generator/" ) - public class TbMsgGeneratorNode implements TbNode { private static final Set supportedEntityTypes = EnumSet.of(EntityType.DEVICE, EntityType.ASSET, EntityType.ENTITY_VIEW, diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java index ce5fba102a..3a78004820 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java @@ -46,6 +46,7 @@ import java.util.concurrent.TimeUnit; import static org.thingsboard.server.common.data.DataConstants.QUEUE_NAME; +@Slf4j @RuleNode( type = ComponentType.TRANSFORMATION, name = "deduplication", @@ -59,9 +60,9 @@ import static org.thingsboard.server.common.data.DataConstants.QUEUE_NAME; "
  • ALL - return all messages as a single JSON array message. " + "Where each element represents object with msg and metadata inner properties.
  • ", icon = "content_copy", - configDirective = "tbTransformationNodeDeduplicationConfig" + configDirective = "tbTransformationNodeDeduplicationConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/deduplication/" ) -@Slf4j public class TbMsgDeduplicationNode implements TbNode { public static final long TB_MSG_DEDUPLICATION_RETRY_DELAY = 10L; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java index 3507f7c500..4cda58e290 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.delay; -import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.math.NumberUtils; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -34,7 +33,6 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "delay (deprecated)", @@ -45,7 +43,8 @@ import java.util.concurrent.TimeUnit; "Deprecated because the acknowledged message still stays in memory (to be delayed) and this " + "does not guarantee that message will be processed even if the \"retry failures and timeouts\" processing strategy will be chosen.", icon = "pause", - configDirective = "tbActionNodeMsgDelayConfig" + configDirective = "tbActionNodeMsgDelayConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/delay/" ) public class TbMsgDelayNode implements TbNode { @@ -109,4 +108,5 @@ public class TbMsgDelayNode implements TbNode { public void destroy() { pendingMsgs.clear(); } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/AbstractTbMsgPushNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/AbstractTbMsgPushNode.java index d1016ee4fe..04ec95e60f 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/AbstractTbMsgPushNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/AbstractTbMsgPushNode.java @@ -41,6 +41,9 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.ACTIVITY_EVENT; import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM; import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_ACK; import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_CLEAR; +import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_CREATED; +import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_SEVERITY_UPDATED; +import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_UPDATED; import static org.thingsboard.server.common.data.msg.TbMsgType.ATTRIBUTES_DELETED; import static org.thingsboard.server.common.data.msg.TbMsgType.ATTRIBUTES_UPDATED; import static org.thingsboard.server.common.data.msg.TbMsgType.CONNECT_EVENT; @@ -78,7 +81,7 @@ public abstract class AbstractTbMsgPushNodeSuccess route.", configDirective = "tbActionNodePushToCloudConfig", icon = "cloud_upload", - ruleChainTypes = RuleChainType.EDGE + ruleChainTypes = RuleChainType.EDGE, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/push-to-cloud/" ) public class TbMsgPushToCloudNode extends AbstractTbMsgPushNode { @@ -80,7 +79,6 @@ public class TbMsgPushToCloudNode extends AbstractTbMsgPushNodeSuccess route.", configDirective = "tbActionNodePushToEdgeConfig", icon = "cloud_download", - ruleChainTypes = RuleChainType.CORE + ruleChainTypes = RuleChainType.CORE, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/push-to-edge/" ) public class TbMsgPushToEdgeNode extends AbstractTbMsgPushNode { @@ -113,7 +115,7 @@ public class TbMsgPushToEdgeNode extends AbstractTbMsgPushNodeAsset profile name or Failure", - configDirective = "tbNodeEmptyConfig") + configDirective = "tbNodeEmptyConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/asset-profile-switch/" +) public class TbAssetTypeSwitchNode extends TbAbstractTypeSwitchNode { @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckAlarmStatusNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckAlarmStatusNode.java index 9fb60ca671..8f0f35ea46 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckAlarmStatusNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckAlarmStatusNode.java @@ -43,7 +43,9 @@ import java.util.Objects; nodeDescription = "Checks alarm status.", nodeDetails = "Checks the alarm status to match one of the specified statuses.

    " + "Output connections: True, False, Failure.", - configDirective = "tbFilterNodeCheckAlarmStatusConfig") + configDirective = "tbFilterNodeCheckAlarmStatusConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/alarm-status-filter/" +) public class TbCheckAlarmStatusNode implements TbNode { private TbCheckAlarmStatusNodeConfig config; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckMessageNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckMessageNode.java index d59f2bb0e4..8e23dae658 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckMessageNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckMessageNode.java @@ -16,7 +16,6 @@ package org.thingsboard.rule.engine.filter; import com.google.gson.Gson; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; @@ -30,7 +29,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.List; import java.util.Map; -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "check fields presence", @@ -40,7 +38,9 @@ import java.util.Map; nodeDetails = "By default, the rule node checks that all specified fields are present. " + "Uncheck the 'Check that all selected fields are present' if the presence of at least one field is sufficient.

    " + "Output connections: True, False, Failure", - configDirective = "tbFilterNodeCheckMessageConfig") + configDirective = "tbFilterNodeCheckMessageConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/check-fields-presence/" +) public class TbCheckMessageNode implements TbNode { private static final Gson gson = new Gson(); @@ -127,4 +127,4 @@ public class TbCheckMessageNode implements TbNode { return (Map) gson.fromJson(msg.getData(), Map.class); } -} \ No newline at end of file +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckRelationNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckRelationNode.java index a5f694373a..6186fe80df 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckRelationNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckRelationNode.java @@ -19,7 +19,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; @@ -41,10 +40,6 @@ import java.util.List; import static org.thingsboard.common.util.DonAsynchron.withCallback; -/** - * Created by ashvayka on 19.01.18. - */ -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "check relation presence", @@ -56,7 +51,9 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; "Otherwise, the rule node checks the presence of a relation to any entity. " + "In both cases, relation lookup is based on configured direction and type.

    " + "Output connections: True, False, Failure", - configDirective = "tbFilterNodeCheckRelationConfig") + configDirective = "tbFilterNodeCheckRelationConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/check-relation-presence/" +) public class TbCheckRelationNode implements TbNode { private TbCheckRelationNodeConfiguration config; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbDeviceTypeSwitchNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbDeviceTypeSwitchNode.java index 8f6954956a..d12055c422 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbDeviceTypeSwitchNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbDeviceTypeSwitchNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.filter; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -26,7 +25,6 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.plugin.ComponentType; -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "device profile switch", @@ -36,7 +34,9 @@ import org.thingsboard.server.common.data.plugin.ComponentType; nodeDescription = "Route incoming messages based on the name of the device profile", nodeDetails = "Route incoming messages based on the name of the device profile. The device profile name is case-sensitive

    " + "Output connections: Device profile name or Failure", - configDirective = "tbNodeEmptyConfig") + configDirective = "tbNodeEmptyConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/device-profile-switch/" +) public class TbDeviceTypeSwitchNode extends TbAbstractTypeSwitchNode { @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java index e36494a2b6..5165eba35f 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.filter; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.ScriptEngine; import org.thingsboard.rule.engine.api.TbContext; @@ -30,7 +29,6 @@ import org.thingsboard.server.common.msg.TbMsg; import static org.thingsboard.common.util.DonAsynchron.withCallback; -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "script", @@ -44,18 +42,17 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; "Message metadata can be accessed via metadata property. For example metadata.customerName === 'John';
    " + "Message type can be accessed via msgType property.

    " + "Output connections: True, False, Failure", - configDirective = "tbFilterNodeScriptConfig" + configDirective = "tbFilterNodeScriptConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/script/" ) public class TbJsFilterNode implements TbNode { - private TbJsFilterNodeConfiguration config; private ScriptEngine scriptEngine; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbJsFilterNodeConfiguration.class); - scriptEngine = ctx.createScriptEngine(config.getScriptLang(), - ScriptLanguage.TBEL.equals(config.getScriptLang()) ? config.getTbelScript() : config.getJsScript()); + var config = TbNodeUtils.convert(configuration, TbJsFilterNodeConfiguration.class); + scriptEngine = ctx.createScriptEngine(config.getScriptLang(), ScriptLanguage.TBEL.equals(config.getScriptLang()) ? config.getTbelScript() : config.getJsScript()); } @Override @@ -75,4 +72,5 @@ public class TbJsFilterNode implements TbNode { scriptEngine.destroy(); } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java index e039eaeb14..87bb2113d9 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.filter; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; import org.checkerframework.checker.nullness.qual.Nullable; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.ScriptEngine; @@ -33,7 +32,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.Set; -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "switch", customRelations = true, @@ -46,17 +44,17 @@ import java.util.Set; "Message metadata can be accessed via metadata property. For example metadata.customerName === 'John';
    " + "Message type can be accessed via msgType property.

    " + "Output connections: Custom connection(s) defined by switch node or Failure", - configDirective = "tbFilterNodeSwitchConfig") + configDirective = "tbFilterNodeSwitchConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/switch/" +) public class TbJsSwitchNode implements TbNode { - private TbJsSwitchNodeConfiguration config; private ScriptEngine scriptEngine; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbJsSwitchNodeConfiguration.class); - this.scriptEngine = ctx.createScriptEngine(config.getScriptLang(), - ScriptLanguage.TBEL.equals(config.getScriptLang()) ? config.getTbelScript() : config.getJsScript()); + var config = TbNodeUtils.convert(configuration, TbJsSwitchNodeConfiguration.class); + scriptEngine = ctx.createScriptEngine(config.getScriptLang(), ScriptLanguage.TBEL.equals(config.getScriptLang()) ? config.getTbelScript() : config.getJsScript()); } @Override @@ -84,4 +82,5 @@ public class TbJsSwitchNode implements TbNode { scriptEngine.destroy(); } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeFilterNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeFilterNode.java index 0e26a89afb..09479de488 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeFilterNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeFilterNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.filter; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; @@ -26,10 +25,6 @@ import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -/** - * Created by ashvayka on 19.01.18. - */ -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "message type filter", @@ -38,14 +33,16 @@ import org.thingsboard.server.common.msg.TbMsg; nodeDescription = "Filter incoming messages by Message Type", nodeDetails = "If incoming message type is expected - send Message via True chain, otherwise False chain is used.

    " + "Output connections: True, False, Failure", - configDirective = "tbFilterNodeMessageTypeConfig") + configDirective = "tbFilterNodeMessageTypeConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/message-type-filter/" +) public class TbMsgTypeFilterNode implements TbNode { - TbMsgTypeFilterNodeConfiguration config; + private TbMsgTypeFilterNodeConfiguration config; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbMsgTypeFilterNodeConfiguration.class); + config = TbNodeUtils.convert(configuration, TbMsgTypeFilterNodeConfiguration.class); } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java index f8d8278cbd..b82eba4e50 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java @@ -15,18 +15,14 @@ */ package org.thingsboard.rule.engine.filter; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; -import org.thingsboard.rule.engine.api.TbNodeException; -import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "message type switch", @@ -36,15 +32,13 @@ import org.thingsboard.server.common.msg.TbMsg; nodeDetails = "Sends messages with message types \"Post attributes\", \"Post telemetry\", \"RPC Request\"" + " etc. via corresponding chain, otherwise Other chain is used.

    " + "Output connections: Message type connection, Other - if message type is custom or Failure", - configDirective = "tbNodeEmptyConfig") + configDirective = "tbNodeEmptyConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/message-type-switch/" +) public class TbMsgTypeSwitchNode implements TbNode { - EmptyNodeConfiguration config; - @Override - public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, EmptyNodeConfiguration.class); - } + public void init(TbContext ctx, TbNodeConfiguration configuration) {} @Override public void onMsg(TbContext ctx, TbMsg msg) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeFilterNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeFilterNode.java index ffaea592f0..57cce66e21 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeFilterNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeFilterNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.filter; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; @@ -27,7 +26,6 @@ import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "entity type filter", @@ -36,14 +34,16 @@ import org.thingsboard.server.common.msg.TbMsg; nodeDescription = "Filter incoming messages by the type of message originator entity", nodeDetails = "Checks that the entity type of the incoming message originator matches one of the values specified in the filter.

    " + "Output connections: True, False, Failure", - configDirective = "tbFilterNodeOriginatorTypeConfig") + configDirective = "tbFilterNodeOriginatorTypeConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/entity-type-filter/" +) public class TbOriginatorTypeFilterNode implements TbNode { - TbOriginatorTypeFilterNodeConfiguration config; + private TbOriginatorTypeFilterNodeConfiguration config; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbOriginatorTypeFilterNodeConfiguration.class); + config = TbNodeUtils.convert(configuration, TbOriginatorTypeFilterNodeConfiguration.class); } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeSwitchNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeSwitchNode.java index 5c2a887b1d..5cee36cf14 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeSwitchNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeSwitchNode.java @@ -15,14 +15,12 @@ */ package org.thingsboard.rule.engine.filter; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.plugin.ComponentType; -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "entity type switch", @@ -31,7 +29,9 @@ import org.thingsboard.server.common.data.plugin.ComponentType; nodeDescription = "Route incoming messages by Message Originator Type", nodeDetails = "Routes messages to chain according to the entity type ('Device', 'Asset', etc.).

    " + "Output connections: Message originator type or Failure", - configDirective = "tbNodeEmptyConfig") + configDirective = "tbNodeEmptyConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/entity-type-switch/" +) public class TbOriginatorTypeSwitchNode extends TbAbstractTypeSwitchNode { @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbAckNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbAckNode.java index 8692cd6c55..b3d1ca71cf 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbAckNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbAckNode.java @@ -15,34 +15,27 @@ */ package org.thingsboard.rule.engine.flow; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; -import org.thingsboard.rule.engine.api.TbNodeException; -import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -@Slf4j @RuleNode( type = ComponentType.FLOW, name = "acknowledge", configClazz = EmptyNodeConfiguration.class, nodeDescription = "Acknowledges the incoming message", nodeDetails = "After acknowledgement, the message is pushed to related rule nodes. Useful if you don't care what happens to this message next.", - configDirective = "tbNodeEmptyConfig" + configDirective = "tbNodeEmptyConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/flow/acknowledge/" ) public class TbAckNode implements TbNode { - EmptyNodeConfiguration config; - @Override - public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, EmptyNodeConfiguration.class); - } + public void init(TbContext ctx, TbNodeConfiguration configuration) {} @Override public void onMsg(TbContext ctx, TbMsg msg) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbCheckpointNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbCheckpointNode.java index d7da783172..dee340af13 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbCheckpointNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbCheckpointNode.java @@ -17,7 +17,6 @@ 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.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -31,7 +30,6 @@ import org.thingsboard.server.common.msg.TbMsg; import static org.thingsboard.server.common.data.DataConstants.QUEUE_NAME; -@Slf4j @RuleNode( type = ComponentType.FLOW, name = "checkpoint", @@ -40,7 +38,8 @@ import static org.thingsboard.server.common.data.DataConstants.QUEUE_NAME; hasQueueName = true, nodeDescription = "transfers the message to another queue", nodeDetails = "After successful transfer incoming message is automatically acknowledged. Queue name is configurable.", - configDirective = "tbNodeEmptyConfig" + configDirective = "tbNodeEmptyConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/flow/checkpoint/" ) public class TbCheckpointNode implements TbNode { @@ -48,7 +47,7 @@ public class TbCheckpointNode implements TbNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.queueName = ctx.getQueueName(); + queueName = ctx.getQueueName(); } @Override 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 3c72699884..a8efa6ddd7 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 @@ -17,7 +17,6 @@ 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; import org.thingsboard.rule.engine.api.TbNode; @@ -34,7 +33,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.Optional; import java.util.UUID; -@Slf4j @RuleNode( type = ComponentType.FLOW, name = "rule chain", @@ -49,7 +47,8 @@ import java.util.UUID; configDirective = "tbFlowNodeRuleChainInputConfig", relationTypes = {}, ruleChainNode = true, - customRelations = true + customRelations = true, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/flow/rule-chain/" ) public class TbRuleChainInputNode implements TbNode { @@ -106,4 +105,5 @@ public class TbRuleChainInputNode implements TbNode { default -> null; }); } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainOutputNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainOutputNode.java index 968ba741a3..88eba3573b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainOutputNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainOutputNode.java @@ -15,17 +15,14 @@ */ package org.thingsboard.rule.engine.flow; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; -import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -@Slf4j @RuleNode( type = ComponentType.FLOW, name = "output", @@ -35,13 +32,13 @@ import org.thingsboard.server.common.msg.TbMsg; "The output is forwarded to the caller rule chain, as an output of the corresponding \"input\" rule node. " + "The output rule node name corresponds to the relation type of the output message, and it is used to forward messages to other rule nodes in the caller rule chain. ", configDirective = "tbFlowNodeRuleChainOutputConfig", - outEnabled = false + outEnabled = false, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/flow/output/" ) public class TbRuleChainOutputNode implements TbNode { @Override - public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - } + public void init(TbContext ctx, TbNodeConfiguration configuration) {} @Override public void onMsg(TbContext ctx, TbMsg msg) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/gcp/pubsub/TbPubSubNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/gcp/pubsub/TbPubSubNode.java index ea893cc71b..2c170f7a2d 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/gcp/pubsub/TbPubSubNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/gcp/pubsub/TbPubSubNode.java @@ -53,7 +53,8 @@ import java.util.concurrent.TimeUnit; "(messageId in the Message Metadata from the GCP PubSub. " + "messageId field can be accessed with metadata.messageId.", configDirective = "tbExternalNodePubSubConfig", - iconUrl = "data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiB2aWV3Qm94PSIwIDAgMTI4IDEyOCI+Cjx0aXRsZT5DbG91ZCBQdWJTdWI8L3RpdGxlPgo8Zz4KPHBhdGggZD0iTTEyNi40Nyw1OC4xMmwtMjYuMy00NS43NEExMS41NiwxMS41NiwwLDAsMCw5MC4zMSw2LjVIMzcuN2ExMS41NSwxMS41NSwwLDAsMC05Ljg2LDUuODhMMS41Myw1OGExMS40OCwxMS40OCwwLDAsMCwwLDExLjQ0bDI2LjMsNDZhMTEuNzcsMTEuNzcsMCwwLDAsOS44Niw2LjA5SDkwLjNhMTEuNzMsMTEuNzMsMCwwLDAsOS44Ny02LjA2bDI2LjMtNDUuNzRBMTEuNzMsMTEuNzMsMCwwLDAsMTI2LjQ3LDU4LjEyWiIgc3R5bGU9ImZpbGw6ICM3MzViMmYiLz4KPHBhdGggZD0iTTg5LjIyLDQ3Ljc0LDgzLjM2LDQ5bC0xNC42LTE0LjZMNjQuMDksNDMuMSw2MS41NSw1My4ybDQuMjksNC4yOUw1Ny42LDU5LjE4LDQ2LjMsNDcuODhsLTcuNjcsNy4zOEw1Mi43Niw2OS4zN2wtMTUsMTEuOUw3OCwxMjEuNUg5MC4zYTExLjczLDExLjczLDAsMCwwLDkuODctNi4wNmwyMC43Mi0zNloiIHN0eWxlPSJvcGFjaXR5OiAwLjA3MDAwMDAwMDI5ODAyMztpc29sYXRpb246IGlzb2xhdGUiLz4KPHBhdGggZD0iTTgyLjg2LDQ3YTUuMzIsNS4zMiwwLDEsMS0xLjk1LDcuMjdBNS4zMiw1LjMyLDAsMCwxLDgyLjg2LDQ3IiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8cGF0aCBkPSJNMzkuODIsNTYuMThhNS4zMiw1LjMyLDAsMSwxLDcuMjctMS45NSw1LjMyLDUuMzIsMCwwLDEtNy4yNywxLjk1IiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8cGF0aCBkPSJNNjkuMzIsODguODVBNS4zMiw1LjMyLDAsMSwxLDY0LDgzLjUyYTUuMzIsNS4zMiwwLDAsMSw1LjMyLDUuMzIiIHN0eWxlPSJmaWxsOiAjZmZmIi8+CjxnPgo8cGF0aCBkPSJNNjQsNTIuOTRhMTEuMDYsMTEuMDYsMCwwLDEsMi40Ni4yOFYzOS4xNUg2MS41NFY1My4yMkExMS4wNiwxMS4wNiwwLDAsMSw2NCw1Mi45NFoiIHN0eWxlPSJmaWxsOiAjZmZmIi8+CjxwYXRoIGQ9Ik03NC41Nyw2Ny4yNmExMSwxMSwwLDAsMS0yLjQ3LDQuMjVsMTIuMTksNywyLjQ2LTQuMjZaIiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8cGF0aCBkPSJNNTMuNDMsNjcuMjZsLTEyLjE4LDcsMi40Niw0LjI2LDEyLjE5LTdBMTEsMTEsMCwwLDEsNTMuNDMsNjcuMjZaIiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8L2c+CjxwYXRoIGQ9Ik03Mi42LDY0QTguNiw4LjYsMCwxLDEsNjQsNTUuNCw4LjYsOC42LDAsMCwxLDcyLjYsNjQiIHN0eWxlPSJmaWxsOiAjZmZmIi8+CjxwYXRoIGQ9Ik0zOS4xLDcwLjU3YTYuNzYsNi43NiwwLDEsMS0yLjQ3LDkuMjMsNi43Niw2Ljc2LDAsMCwxLDIuNDctOS4yMyIgc3R5bGU9ImZpbGw6ICNmZmYiLz4KPHBhdGggZD0iTTgyLjE0LDgyLjI3YTYuNzYsNi43NiwwLDEsMSw5LjIzLTIuNDcsNi43NSw2Ljc1LDAsMCwxLTkuMjMsMi40NyIgc3R5bGU9ImZpbGw6ICNmZmYiLz4KPHBhdGggZD0iTTcwLjc2LDM5LjE1QTYuNzYsNi43NiwwLDEsMSw2NCwzMi4zOWE2Ljc2LDYuNzYsMCwwLDEsNi43Niw2Ljc2IiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8L2c+Cjwvc3ZnPgo=" + iconUrl = "data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiB2aWV3Qm94PSIwIDAgMTI4IDEyOCI+Cjx0aXRsZT5DbG91ZCBQdWJTdWI8L3RpdGxlPgo8Zz4KPHBhdGggZD0iTTEyNi40Nyw1OC4xMmwtMjYuMy00NS43NEExMS41NiwxMS41NiwwLDAsMCw5MC4zMSw2LjVIMzcuN2ExMS41NSwxMS41NSwwLDAsMC05Ljg2LDUuODhMMS41Myw1OGExMS40OCwxMS40OCwwLDAsMCwwLDExLjQ0bDI2LjMsNDZhMTEuNzcsMTEuNzcsMCwwLDAsOS44Niw2LjA5SDkwLjNhMTEuNzMsMTEuNzMsMCwwLDAsOS44Ny02LjA2bDI2LjMtNDUuNzRBMTEuNzMsMTEuNzMsMCwwLDAsMTI2LjQ3LDU4LjEyWiIgc3R5bGU9ImZpbGw6ICM3MzViMmYiLz4KPHBhdGggZD0iTTg5LjIyLDQ3Ljc0LDgzLjM2LDQ5bC0xNC42LTE0LjZMNjQuMDksNDMuMSw2MS41NSw1My4ybDQuMjksNC4yOUw1Ny42LDU5LjE4LDQ2LjMsNDcuODhsLTcuNjcsNy4zOEw1Mi43Niw2OS4zN2wtMTUsMTEuOUw3OCwxMjEuNUg5MC4zYTExLjczLDExLjczLDAsMCwwLDkuODctNi4wNmwyMC43Mi0zNloiIHN0eWxlPSJvcGFjaXR5OiAwLjA3MDAwMDAwMDI5ODAyMztpc29sYXRpb246IGlzb2xhdGUiLz4KPHBhdGggZD0iTTgyLjg2LDQ3YTUuMzIsNS4zMiwwLDEsMS0xLjk1LDcuMjdBNS4zMiw1LjMyLDAsMCwxLDgyLjg2LDQ3IiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8cGF0aCBkPSJNMzkuODIsNTYuMThhNS4zMiw1LjMyLDAsMSwxLDcuMjctMS45NSw1LjMyLDUuMzIsMCwwLDEtNy4yNywxLjk1IiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8cGF0aCBkPSJNNjkuMzIsODguODVBNS4zMiw1LjMyLDAsMSwxLDY0LDgzLjUyYTUuMzIsNS4zMiwwLDAsMSw1LjMyLDUuMzIiIHN0eWxlPSJmaWxsOiAjZmZmIi8+CjxnPgo8cGF0aCBkPSJNNjQsNTIuOTRhMTEuMDYsMTEuMDYsMCwwLDEsMi40Ni4yOFYzOS4xNUg2MS41NFY1My4yMkExMS4wNiwxMS4wNiwwLDAsMSw2NCw1Mi45NFoiIHN0eWxlPSJmaWxsOiAjZmZmIi8+CjxwYXRoIGQ9Ik03NC41Nyw2Ny4yNmExMSwxMSwwLDAsMS0yLjQ3LDQuMjVsMTIuMTksNywyLjQ2LTQuMjZaIiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8cGF0aCBkPSJNNTMuNDMsNjcuMjZsLTEyLjE4LDcsMi40Niw0LjI2LDEyLjE5LTdBMTEsMTEsMCwwLDEsNTMuNDMsNjcuMjZaIiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8L2c+CjxwYXRoIGQ9Ik03Mi42LDY0QTguNiw4LjYsMCwxLDEsNjQsNTUuNCw4LjYsOC42LDAsMCwxLDcyLjYsNjQiIHN0eWxlPSJmaWxsOiAjZmZmIi8+CjxwYXRoIGQ9Ik0zOS4xLDcwLjU3YTYuNzYsNi43NiwwLDEsMS0yLjQ3LDkuMjMsNi43Niw2Ljc2LDAsMCwxLDIuNDctOS4yMyIgc3R5bGU9ImZpbGw6ICNmZmYiLz4KPHBhdGggZD0iTTgyLjE0LDgyLjI3YTYuNzYsNi43NiwwLDEsMSw5LjIzLTIuNDcsNi43NSw2Ljc1LDAsMCwxLTkuMjMsMi40NyIgc3R5bGU9ImZpbGw6ICNmZmYiLz4KPHBhdGggZD0iTTcwLjc2LDM5LjE1QTYuNzYsNi43NiwwLDEsMSw2NCwzMi4zOWE2Ljc2LDYuNzYsMCwwLDEsNi43Niw2Ljc2IiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8L2c+Cjwvc3ZnPgo=", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/gcp-pubsub/" ) public class TbPubSubNode extends TbAbstractExternalNode { @@ -155,4 +156,5 @@ public class TbPubSubNode extends TbAbstractExternalNode { .setExecutorProvider(FixedExecutorProvider.create(ctx.getPubSubRuleNodeExecutorProvider().getExecutor())) .build(); } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNode.java index b362b60b8e..488ee2123e 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNode.java @@ -20,7 +20,6 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.gson.Gson; import com.google.gson.JsonObject; import com.google.gson.JsonParser; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeException; @@ -34,10 +33,10 @@ import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -47,10 +46,6 @@ import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.INSIDE; import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.LEFT; import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.OUTSIDE; -/** - * Created by ashvayka on 19.01.18. - */ -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "gps geofencing events", @@ -66,12 +61,13 @@ import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.OUTSIDE; "If the presence monitoring strategy \"On each message\" is selected, sends messages via rule node connection type Inside or Outside every time the geofencing condition is satisfied. " + "

    " + "Output connections: Entered, Left, Inside, Outside, Success", - configDirective = "tbActionNodeGpsGeofencingConfig" + configDirective = "tbActionNodeGpsGeofencingConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/gps-geofencing-events/" ) public class TbGpsGeofencingActionNode extends AbstractGeofencingNode { private static final String REPORT_PRESENCE_STATUS_ON_EACH_MESSAGE = "reportPresenceStatusOnEachMessage"; - private final Map entityStates = new HashMap<>(); + private final ConcurrentMap entityStates = new ConcurrentHashMap<>(); private final Gson gson = new Gson(); @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingFilterNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingFilterNode.java index 19c901483c..7c1a561492 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingFilterNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingFilterNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.geo; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeException; @@ -23,10 +22,6 @@ import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -/** - * Created by ashvayka on 19.01.18. - */ -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "gps geofencing filter", @@ -60,7 +55,9 @@ import org.thingsboard.server.common.msg.TbMsg; "

    " + "Available radius units: METER, KILOMETER, FOOT, MILE, NAUTICAL_MILE;

    " + "Output connections: True, False, Failure", - configDirective = "tbFilterNodeGpsGeofencingConfig") + configDirective = "tbFilterNodeGpsGeofencingConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/gps-geofencing-filter/" +) public class TbGpsGeofencingFilterNode extends AbstractGeofencingNode { @Override @@ -72,4 +69,5 @@ public class TbGpsGeofencingFilterNode extends AbstractGeofencingNode getConfigClazz() { return TbGpsGeofencingFilterNodeConfiguration.class; } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java index d544d0647d..a6549dbd15 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java @@ -58,7 +58,8 @@ import java.util.Properties; "Outbound message will contain response fields (offset, partition, topic)" + " from the Kafka in the Message Metadata. For example partition field can be accessed with metadata.partition.", configDirective = "tbExternalNodeKafkaConfig", - iconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTUzOCIgaGVpZ2h0PSIyNTAwIiB2aWV3Qm94PSIwIDAgMjU2IDQxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCI+PHBhdGggZD0iTTIwMS44MTYgMjMwLjIxNmMtMTYuMTg2IDAtMzAuNjk3IDcuMTcxLTQwLjYzNCAxOC40NjFsLTI1LjQ2My0xOC4wMjZjMi43MDMtNy40NDIgNC4yNTUtMTUuNDMzIDQuMjU1LTIzLjc5NyAwLTguMjE5LTEuNDk4LTE2LjA3Ni00LjExMi0yMy40MDhsMjUuNDA2LTE3LjgzNWM5LjkzNiAxMS4yMzMgMjQuNDA5IDE4LjM2NSA0MC41NDggMTguMzY1IDI5Ljg3NSAwIDU0LjE4NC0yNC4zMDUgNTQuMTg0LTU0LjE4NCAwLTI5Ljg3OS0yNC4zMDktNTQuMTg0LTU0LjE4NC01NC4xODQtMjkuODc1IDAtNTQuMTg0IDI0LjMwNS01NC4xODQgNTQuMTg0IDAgNS4zNDguODA4IDEwLjUwNSAyLjI1OCAxNS4zODlsLTI1LjQyMyAxNy44NDRjLTEwLjYyLTEzLjE3NS0yNS45MTEtMjIuMzc0LTQzLjMzMy0yNS4xODJ2LTMwLjY0YzI0LjU0NC01LjE1NSA0My4wMzctMjYuOTYyIDQzLjAzNy01My4wMTlDMTI0LjE3MSAyNC4zMDUgOTkuODYyIDAgNjkuOTg3IDAgNDAuMTEyIDAgMTUuODAzIDI0LjMwNSAxNS44MDMgNTQuMTg0YzAgMjUuNzA4IDE4LjAxNCA0Ny4yNDYgNDIuMDY3IDUyLjc2OXYzMS4wMzhDMjUuMDQ0IDE0My43NTMgMCAxNzIuNDAxIDAgMjA2Ljg1NGMwIDM0LjYyMSAyNS4yOTIgNjMuMzc0IDU4LjM1NSA2OC45NHYzMi43NzRjLTI0LjI5OSA1LjM0MS00Mi41NTIgMjcuMDExLTQyLjU1MiA1Mi44OTQgMCAyOS44NzkgMjQuMzA5IDU0LjE4NCA1NC4xODQgNTQuMTg0IDI5Ljg3NSAwIDU0LjE4NC0yNC4zMDUgNTQuMTg0LTU0LjE4NCAwLTI1Ljg4My0xOC4yNTMtNDcuNTUzLTQyLjU1Mi01Mi44OTR2LTMyLjc3NWE2OS45NjUgNjkuOTY1IDAgMCAwIDQyLjYtMjQuNzc2bDI1LjYzMyAxOC4xNDNjLTEuNDIzIDQuODQtMi4yMiA5Ljk0Ni0yLjIyIDE1LjI0IDAgMjkuODc5IDI0LjMwOSA1NC4xODQgNTQuMTg0IDU0LjE4NCAyOS44NzUgMCA1NC4xODQtMjQuMzA1IDU0LjE4NC01NC4xODQgMC0yOS44NzktMjQuMzA5LTU0LjE4NC01NC4xODQtNTQuMTg0em0wLTEyNi42OTVjMTQuNDg3IDAgMjYuMjcgMTEuNzg4IDI2LjI3IDI2LjI3MXMtMTEuNzgzIDI2LjI3LTI2LjI3IDI2LjI3LTI2LjI3LTExLjc4Ny0yNi4yNy0yNi4yN2MwLTE0LjQ4MyAxMS43ODMtMjYuMjcxIDI2LjI3LTI2LjI3MXptLTE1OC4xLTQ5LjMzN2MwLTE0LjQ4MyAxMS43ODQtMjYuMjcgMjYuMjcxLTI2LjI3czI2LjI3IDExLjc4NyAyNi4yNyAyNi4yN2MwIDE0LjQ4My0xMS43ODMgMjYuMjctMjYuMjcgMjYuMjdzLTI2LjI3MS0xMS43ODctMjYuMjcxLTI2LjI3em01Mi41NDEgMzA3LjI3OGMwIDE0LjQ4My0xMS43ODMgMjYuMjctMjYuMjcgMjYuMjdzLTI2LjI3MS0xMS43ODctMjYuMjcxLTI2LjI3YzAtMTQuNDgzIDExLjc4NC0yNi4yNyAyNi4yNzEtMjYuMjdzMjYuMjcgMTEuNzg3IDI2LjI3IDI2LjI3em0tMjYuMjcyLTExNy45N2MtMjAuMjA1IDAtMzYuNjQyLTE2LjQzNC0zNi42NDItMzYuNjM4IDAtMjAuMjA1IDE2LjQzNy0zNi42NDIgMzYuNjQyLTM2LjY0MiAyMC4yMDQgMCAzNi42NDEgMTYuNDM3IDM2LjY0MSAzNi42NDIgMCAyMC4yMDQtMTYuNDM3IDM2LjYzOC0zNi42NDEgMzYuNjM4em0xMzEuODMxIDY3LjE3OWMtMTQuNDg3IDAtMjYuMjctMTEuNzg4LTI2LjI3LTI2LjI3MXMxMS43ODMtMjYuMjcgMjYuMjctMjYuMjcgMjYuMjcgMTEuNzg3IDI2LjI3IDI2LjI3YzAgMTQuNDgzLTExLjc4MyAyNi4yNzEtMjYuMjcgMjYuMjcxeiIvPjwvc3ZnPg==" + iconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTUzOCIgaGVpZ2h0PSIyNTAwIiB2aWV3Qm94PSIwIDAgMjU2IDQxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCI+PHBhdGggZD0iTTIwMS44MTYgMjMwLjIxNmMtMTYuMTg2IDAtMzAuNjk3IDcuMTcxLTQwLjYzNCAxOC40NjFsLTI1LjQ2My0xOC4wMjZjMi43MDMtNy40NDIgNC4yNTUtMTUuNDMzIDQuMjU1LTIzLjc5NyAwLTguMjE5LTEuNDk4LTE2LjA3Ni00LjExMi0yMy40MDhsMjUuNDA2LTE3LjgzNWM5LjkzNiAxMS4yMzMgMjQuNDA5IDE4LjM2NSA0MC41NDggMTguMzY1IDI5Ljg3NSAwIDU0LjE4NC0yNC4zMDUgNTQuMTg0LTU0LjE4NCAwLTI5Ljg3OS0yNC4zMDktNTQuMTg0LTU0LjE4NC01NC4xODQtMjkuODc1IDAtNTQuMTg0IDI0LjMwNS01NC4xODQgNTQuMTg0IDAgNS4zNDguODA4IDEwLjUwNSAyLjI1OCAxNS4zODlsLTI1LjQyMyAxNy44NDRjLTEwLjYyLTEzLjE3NS0yNS45MTEtMjIuMzc0LTQzLjMzMy0yNS4xODJ2LTMwLjY0YzI0LjU0NC01LjE1NSA0My4wMzctMjYuOTYyIDQzLjAzNy01My4wMTlDMTI0LjE3MSAyNC4zMDUgOTkuODYyIDAgNjkuOTg3IDAgNDAuMTEyIDAgMTUuODAzIDI0LjMwNSAxNS44MDMgNTQuMTg0YzAgMjUuNzA4IDE4LjAxNCA0Ny4yNDYgNDIuMDY3IDUyLjc2OXYzMS4wMzhDMjUuMDQ0IDE0My43NTMgMCAxNzIuNDAxIDAgMjA2Ljg1NGMwIDM0LjYyMSAyNS4yOTIgNjMuMzc0IDU4LjM1NSA2OC45NHYzMi43NzRjLTI0LjI5OSA1LjM0MS00Mi41NTIgMjcuMDExLTQyLjU1MiA1Mi44OTQgMCAyOS44NzkgMjQuMzA5IDU0LjE4NCA1NC4xODQgNTQuMTg0IDI5Ljg3NSAwIDU0LjE4NC0yNC4zMDUgNTQuMTg0LTU0LjE4NCAwLTI1Ljg4My0xOC4yNTMtNDcuNTUzLTQyLjU1Mi01Mi44OTR2LTMyLjc3NWE2OS45NjUgNjkuOTY1IDAgMCAwIDQyLjYtMjQuNzc2bDI1LjYzMyAxOC4xNDNjLTEuNDIzIDQuODQtMi4yMiA5Ljk0Ni0yLjIyIDE1LjI0IDAgMjkuODc5IDI0LjMwOSA1NC4xODQgNTQuMTg0IDU0LjE4NCAyOS44NzUgMCA1NC4xODQtMjQuMzA1IDU0LjE4NC01NC4xODQgMC0yOS44NzktMjQuMzA5LTU0LjE4NC01NC4xODQtNTQuMTg0em0wLTEyNi42OTVjMTQuNDg3IDAgMjYuMjcgMTEuNzg4IDI2LjI3IDI2LjI3MXMtMTEuNzgzIDI2LjI3LTI2LjI3IDI2LjI3LTI2LjI3LTExLjc4Ny0yNi4yNy0yNi4yN2MwLTE0LjQ4MyAxMS43ODMtMjYuMjcxIDI2LjI3LTI2LjI3MXptLTE1OC4xLTQ5LjMzN2MwLTE0LjQ4MyAxMS43ODQtMjYuMjcgMjYuMjcxLTI2LjI3czI2LjI3IDExLjc4NyAyNi4yNyAyNi4yN2MwIDE0LjQ4My0xMS43ODMgMjYuMjctMjYuMjcgMjYuMjdzLTI2LjI3MS0xMS43ODctMjYuMjcxLTI2LjI3em01Mi41NDEgMzA3LjI3OGMwIDE0LjQ4My0xMS43ODMgMjYuMjctMjYuMjcgMjYuMjdzLTI2LjI3MS0xMS43ODctMjYuMjcxLTI2LjI3YzAtMTQuNDgzIDExLjc4NC0yNi4yNyAyNi4yNzEtMjYuMjdzMjYuMjcgMTEuNzg3IDI2LjI3IDI2LjI3em0tMjYuMjcyLTExNy45N2MtMjAuMjA1IDAtMzYuNjQyLTE2LjQzNC0zNi42NDItMzYuNjM4IDAtMjAuMjA1IDE2LjQzNy0zNi42NDIgMzYuNjQyLTM2LjY0MiAyMC4yMDQgMCAzNi42NDEgMTYuNDM3IDM2LjY0MSAzNi42NDIgMCAyMC4yMDQtMTYuNDM3IDM2LjYzOC0zNi42NDEgMzYuNjM4em0xMzEuODMxIDY3LjE3OWMtMTQuNDg3IDAtMjYuMjctMTEuNzg4LTI2LjI3LTI2LjI3MXMxMS43ODMtMjYuMjcgMjYuMjctMjYuMjcgMjYuMjcgMTEuNzg3IDI2LjI3IDI2LjI3YzAgMTQuNDgzLTExLjc4MyAyNi4yNzEtMjYuMjcgMjYuMjcxeiIvPjwvc3ZnPg==", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/kafka/" ) public class TbKafkaNode extends TbAbstractExternalNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbMsgToEmailNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbMsgToEmailNode.java index f20aeca55f..4867735a2b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbMsgToEmailNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbMsgToEmailNode.java @@ -16,7 +16,6 @@ package org.thingsboard.rule.engine.mail; import com.fasterxml.jackson.core.type.TypeReference; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -34,7 +33,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.HashMap; import java.util.Map; -@Slf4j @RuleNode( type = ComponentType.TRANSFORMATION, name = "to email", @@ -43,7 +41,8 @@ import java.util.Map; nodeDetails = "Transforms message to email message. If transformation completed successfully output message type will be set to SEND_EMAIL.

    " + "Output connections: Success, Failure.", configDirective = "tbTransformationNodeToEmailConfig", - icon = "email" + icon = "email", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/to-email/" ) public class TbMsgToEmailNode implements TbNode { @@ -55,20 +54,15 @@ public class TbMsgToEmailNode implements TbNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbMsgToEmailNodeConfiguration.class); - this.dynamicMailBodyType = DYNAMIC.equals(this.config.getMailBodyType()); - } + config = TbNodeUtils.convert(configuration, TbMsgToEmailNodeConfiguration.class); + dynamicMailBodyType = DYNAMIC.equals(config.getMailBodyType()); + } @Override public void onMsg(TbContext ctx, TbMsg msg) { - try { - TbEmail email = convert(msg); - TbMsg emailMsg = buildEmailMsg(ctx, msg, email); - ctx.tellNext(emailMsg, TbNodeConnectionType.SUCCESS); - } catch (Exception ex) { - log.warn("Can not convert message to email " + ex.getMessage()); - ctx.tellFailure(msg, ex); - } + TbEmail email = convert(msg); + TbMsg emailMsg = buildEmailMsg(ctx, msg, email); + ctx.tellNext(emailMsg, TbNodeConnectionType.SUCCESS); } private TbMsg buildEmailMsg(TbContext ctx, TbMsg msg, TbEmail email) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbSendEmailNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbSendEmailNode.java index 630d87cb54..198808297e 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbSendEmailNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbSendEmailNode.java @@ -45,7 +45,8 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; " where created using to Email transformation Node, please connect this Node " + "with to Email Node using Successful chain.", configDirective = "tbExternalNodeSendEmailConfig", - icon = "send" + icon = "send", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/send-email/" ) public class TbSendEmailNode extends TbAbstractExternalNode { @@ -91,7 +92,7 @@ public class TbSendEmailNode extends TbAbstractExternalNode { } } - private TbEmail getEmail(TbMsg msg) throws IOException { + private TbEmail getEmail(TbMsg msg) { TbEmail email = JacksonUtil.fromString(msg.getData(), TbEmail.class); if (StringUtils.isBlank(email.getTo())) { throw new IllegalStateException("Email destination can not be blank [" + email.getTo() + "]"); @@ -141,4 +142,5 @@ public class TbSendEmailNode extends TbAbstractExternalNode { } return javaMailProperties; } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNode.java index 76ba71163a..82dd20cff4 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNode.java @@ -20,10 +20,9 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.SettableFuture; -import lombok.extern.slf4j.Slf4j; import net.objecthunter.exp4j.Expression; -import net.objecthunter.exp4j.ExpressionBuilder; import org.springframework.util.ConcurrentReferenceHashMap; +import org.thingsboard.common.util.ExpressionUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.rule.engine.api.RuleNode; @@ -53,11 +52,8 @@ import java.util.function.BiFunction; import java.util.function.Function; import java.util.stream.Collectors; -import static org.thingsboard.common.util.ExpressionFunctionsUtil.userDefinedFunctions; import static org.thingsboard.rule.engine.math.TbMathArgumentType.CONSTANT; -@SuppressWarnings("UnstableApiUsage") -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "math function", @@ -78,8 +74,8 @@ import static org.thingsboard.rule.engine.math.TbMathArgumentType.CONSTANT; "The execution is synchronized in scope of message originator (e.g. device) and server node. " + "If you have rule nodes in different rule chains, they will process messages from the same originator synchronously in the scope of the server node.", configDirective = "tbActionNodeMathFunctionConfig", - icon = "calculate" - + icon = "calculate", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/math-function/" ) public class TbMathNode implements TbNode { @@ -310,11 +306,8 @@ public class TbMathNode implements TbNode { case CUSTOM: var expr = customExpression.get(); if (expr == null) { - expr = new ExpressionBuilder(config.getCustomFunction()) - .functions(userDefinedFunctions) - .implicitMultiplication(true) - .variables(config.getArguments().stream().map(TbMathArgument::getName).collect(Collectors.toSet())) - .build(); + expr = ExpressionUtils.createExpression(config.getCustomFunction(), config.getArguments().stream() + .map(TbMathArgument::getName).collect(Collectors.toSet())); customExpression.set(expr); } for (int i = 0; i < config.getArguments().size(); i++) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNode.java index 29c7dc51e5..dd1888f3de 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNode.java @@ -20,7 +20,6 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; @@ -43,8 +42,8 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Map; -@Slf4j -@RuleNode(type = ComponentType.ENRICHMENT, +@RuleNode( + type = ComponentType.ENRICHMENT, name = "calculate delta", version = 1, relationTypes = {TbNodeConnectionType.SUCCESS, TbNodeConnectionType.FAILURE, TbNodeConnectionType.OTHER}, @@ -53,7 +52,9 @@ import java.util.Map; "and current value for this key from the incoming message", nodeDetails = "Useful for metering use cases, when you need to calculate consumption based on pulse counter reading.

    " + "Output connections: Success, Other or Failure.", - configDirective = "tbEnrichmentNodeCalculateDeltaConfig") + configDirective = "tbEnrichmentNodeCalculateDeltaConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/calculate-delta/" +) public class CalculateDeltaNode implements TbNode { private Map cache; @@ -189,7 +190,6 @@ public class CalculateDeltaNode implements TbNode { return fetchLatestValueAsync(ctx, originator); } - private record ValueWithTs(long ts, double value) { - } + private record ValueWithTs(long ts, double value) {} } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbAbstractGetEntityDataNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbAbstractGetEntityDataNode.java index ebf32bf6b1..1982596e91 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbAbstractGetEntityDataNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbAbstractGetEntityDataNode.java @@ -58,15 +58,9 @@ public abstract class TbAbstractGetEntityDataNode extends Tb protected void processDataAndTell(TbContext ctx, TbMsg msg, T entityId, ObjectNode msgDataAsJsonNode) { DataToFetch dataToFetch = config.getDataToFetch(); switch (dataToFetch) { - case ATTRIBUTES: - processAttributesKvEntryData(ctx, msg, entityId, msgDataAsJsonNode); - break; - case LATEST_TELEMETRY: - processTsKvEntryData(ctx, msg, entityId, msgDataAsJsonNode); - break; - case FIELDS: - processFieldsData(ctx, msg, entityId, msgDataAsJsonNode, true); - break; + case ATTRIBUTES -> processAttributesKvEntryData(ctx, msg, entityId, msgDataAsJsonNode); + case LATEST_TELEMETRY -> processTsKvEntryData(ctx, msg, entityId, msgDataAsJsonNode); + case FIELDS -> processFieldsData(ctx, msg, entityId, msgDataAsJsonNode, true); } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbAbstractNodeWithFetchTo.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbAbstractNodeWithFetchTo.java index 189ec47ef1..a11e3333ff 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbAbstractNodeWithFetchTo.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbAbstractNodeWithFetchTo.java @@ -17,8 +17,7 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.google.common.util.concurrent.AsyncFunction; -import com.google.common.util.concurrent.Futures; +import com.google.common.base.Function; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.TbContext; @@ -54,12 +53,12 @@ public abstract class TbAbstractNodeWithFetchTo AsyncFunction checkIfEntityIsPresentOrThrow(String message) { + protected Function checkIfEntityIsPresentOrThrow(String message) { return id -> { if (id == null || id.isNullUid()) { - return Futures.immediateFailedFuture(new NoSuchElementException(message)); + throw new NoSuchElementException(message); } - return Futures.immediateFuture(id); + return id; }; } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbFetchDeviceCredentialsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbFetchDeviceCredentialsNode.java index 294354279a..421da9c768 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbFetchDeviceCredentialsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbFetchDeviceCredentialsNode.java @@ -16,7 +16,6 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -33,7 +32,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.concurrent.ExecutionException; -@Slf4j @RuleNode( type = ComponentType.ENRICHMENT, name = "fetch device credentials", @@ -45,7 +43,9 @@ import java.util.concurrent.ExecutionException; "Useful when you need to fetch device credentials and use them for further message processing. " + "For example, use device credentials to interact with external systems.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeFetchDeviceCredentialsConfig") + configDirective = "tbEnrichmentNodeFetchDeviceCredentialsConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/fetch-device-credentials/" +) public class TbFetchDeviceCredentialsNode extends TbAbstractNodeWithFetchTo { private static final String CREDENTIALS = "credentials"; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetAttributesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetAttributesNode.java index 68ec3bb373..8af44f67f0 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetAttributesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetAttributesNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -30,11 +29,8 @@ import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; -/** - * Created by ashvayka on 19.01.18. - */ -@Slf4j -@RuleNode(type = ComponentType.ENRICHMENT, +@RuleNode( + type = ComponentType.ENRICHMENT, name = "originator attributes", configClazz = TbGetAttributesNodeConfiguration.class, version = 1, @@ -43,7 +39,9 @@ import org.thingsboard.server.common.msg.TbMsg; "that are not included in the incoming message to use them for further message processing. " + "For example to filter messages based on the threshold value stored in the attributes.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeOriginatorAttributesConfig") + configDirective = "tbEnrichmentNodeOriginatorAttributesConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/originator-attributes/" +) public class TbGetAttributesNode extends TbAbstractGetAttributesNode { @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNode.java index 60ca50f2b3..02a1330a53 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNode.java @@ -16,21 +16,22 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; -import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; 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.rule.engine.util.EntitiesCustomerIdAsyncLoader; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; -@Slf4j +import java.util.NoSuchElementException; + +import static com.google.common.util.concurrent.Futures.immediateFuture; + @RuleNode( type = ComponentType.ENRICHMENT, name = "customer attributes", @@ -41,11 +42,11 @@ import org.thingsboard.server.common.data.util.TbPair; "that is stored as customer attributes or telemetry data and used for dynamic message filtering, transformation, " + "or actions such as alarm creation if the threshold is exceeded.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeCustomerAttributesConfig") + configDirective = "tbEnrichmentNodeCustomerAttributesConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/customer-attributes/" +) public class TbGetCustomerAttributeNode extends TbAbstractGetEntityDataNode { - private static final String CUSTOMER_NOT_FOUND_MESSAGE = "Failed to find customer for entity with id: %s and type: %s"; - @Override protected TbGetEntityDataNodeConfiguration loadNodeConfiguration(TbNodeConfiguration configuration) throws TbNodeException { var config = TbNodeUtils.convert(configuration, TbGetEntityDataNodeConfiguration.class); @@ -56,10 +57,19 @@ public class TbGetCustomerAttributeNode extends TbAbstractGetEntityDataNode findEntityAsync(TbContext ctx, EntityId originator) { - return Futures.transformAsync(EntitiesCustomerIdAsyncLoader.findEntityIdAsync(ctx, originator), - checkIfEntityIsPresentOrThrow(String.format(CUSTOMER_NOT_FOUND_MESSAGE, originator.getId(), originator.getEntityType().getNormalName())), - ctx.getDbCallbackExecutor() - ); + if (originator.getEntityType() == EntityType.CUSTOMER) { + return immediateFuture((CustomerId) originator); + } + return ctx.getEntityService().fetchEntityCustomerIdAsync(ctx.getTenantId(), originator) + .transform(customerIdOpt -> { + if (customerIdOpt.isEmpty()) { + throw new NoSuchElementException("Originator not found"); + } + if (customerIdOpt.get().isNullUid()) { + throw new IllegalStateException("Originator is not assigned to any customer"); + } + return customerIdOpt.get(); + }, ctx.getDbCallbackExecutor()); } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNode.java index 55def65d46..7848c3c1c6 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -41,8 +40,8 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.NoSuchElementException; -@Slf4j -@RuleNode(type = ComponentType.ENRICHMENT, +@RuleNode( + type = ComponentType.ENRICHMENT, name = "customer details", configClazz = TbGetCustomerDetailsNodeConfiguration.class, version = 1, @@ -50,7 +49,9 @@ import java.util.NoSuchElementException; nodeDetails = "Useful in multi-customer solutions where we need dynamically use customer contact information " + "such as email, phone, address, etc., for notifications via email, SMS, and other notification providers.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeEntityDetailsConfig") + configDirective = "tbEnrichmentNodeEntityDetailsConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/customer-details/" +) public class TbGetCustomerDetailsNode extends TbAbstractGetEntityDetailsNode { private static final String CUSTOMER_PREFIX = "customer_"; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetDeviceAttrNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetDeviceAttrNode.java index 031aae8859..9dbdba8ebe 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetDeviceAttrNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetDeviceAttrNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -31,8 +30,8 @@ import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; -@Slf4j -@RuleNode(type = ComponentType.ENRICHMENT, +@RuleNode( + type = ComponentType.ENRICHMENT, name = "related device attributes", configClazz = TbGetDeviceAttrNodeConfiguration.class, version = 1, @@ -42,7 +41,9 @@ import org.thingsboard.server.common.msg.TbMsg; "Useful when you need to retrieve attributes and/or latest telemetry values from device that has a relation " + "to the message originator and use them for further message processing.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeDeviceAttributesConfig") + configDirective = "tbEnrichmentNodeDeviceAttributesConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/related-device-attributes/" +) public class TbGetDeviceAttrNode extends TbAbstractGetAttributesNode { private static final String RELATED_DEVICE_NOT_FOUND_MESSAGE = "Failed to find related device to message originator using relation query specified in the configuration!"; @@ -54,7 +55,7 @@ public class TbGetDeviceAttrNode extends TbAbstractGetAttributesNode findEntityIdAsync(TbContext ctx, TbMsg msg) { - return Futures.transformAsync( + return Futures.transform( EntitiesRelatedDeviceIdAsyncLoader.findDeviceAsync(ctx, msg.getOriginator(), config.getDeviceRelationsQuery()), checkIfEntityIsPresentOrThrow(RELATED_DEVICE_NOT_FOUND_MESSAGE), ctx.getDbCallbackExecutor()); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetOriginatorFieldsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetOriginatorFieldsNode.java index 816b198836..1c3269db9d 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetOriginatorFieldsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetOriginatorFieldsNode.java @@ -17,7 +17,6 @@ package org.thingsboard.rule.engine.metadata; 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; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -31,11 +30,8 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.concurrent.ExecutionException; -/** - * Created by ashvayka on 19.01.18. - */ -@Slf4j -@RuleNode(type = ComponentType.ENRICHMENT, +@RuleNode( + type = ComponentType.ENRICHMENT, name = "originator fields", configClazz = TbGetOriginatorFieldsConfiguration.class, version = 1, @@ -43,7 +39,9 @@ import java.util.concurrent.ExecutionException; nodeDetails = "Fetches fields values specified in the mapping. If specified field is not part of originator fields it will be ignored. " + "Useful when you need to retrieve originator fields and use them for further message processing.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeOriginatorFieldsConfig") + configDirective = "tbEnrichmentNodeOriginatorFieldsConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/originator-fields/" +) public class TbGetOriginatorFieldsNode extends TbAbstractGetMappedDataNode { protected final static String DATA_MAPPING_PROPERTY_NAME = "dataMapping"; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetRelatedAttributeNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetRelatedAttributeNode.java index d6df8123c2..fcd11a08f1 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetRelatedAttributeNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetRelatedAttributeNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -31,7 +30,6 @@ import org.thingsboard.server.common.data.util.TbPair; import java.util.Arrays; -@Slf4j @RuleNode( type = ComponentType.ENRICHMENT, name = "related entity data", @@ -42,7 +40,9 @@ import java.util.Arrays; "If multiple related entities are found, only first entity is used for message enrichment, other entities are discarded. " + "Useful when you need to retrieve data from an entity that has a relation to the message originator and use them for further message processing.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeRelatedAttributesConfig") + configDirective = "tbEnrichmentNodeRelatedAttributesConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/related-entity-data/" +) public class TbGetRelatedAttributeNode extends TbAbstractGetEntityDataNode { private static final String RELATED_ENTITY_NOT_FOUND_MESSAGE = "Failed to find related entity to message originator using relation query specified in the configuration!"; @@ -58,7 +58,7 @@ public class TbGetRelatedAttributeNode extends TbAbstractGetEntityDataNode findEntityAsync(TbContext ctx, EntityId originator) { var relatedAttrConfig = (TbGetRelatedDataNodeConfiguration) config; - return Futures.transformAsync( + return Futures.transform( EntitiesRelatedEntityIdAsyncLoader.findEntityAsync(ctx, originator, relatedAttrConfig.getRelationsQuery()), checkIfEntityIsPresentOrThrow(RELATED_ENTITY_NOT_FOUND_MESSAGE), ctx.getDbCallbackExecutor()); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java index cc6988580a..3102b3b3fd 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java @@ -21,7 +21,6 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.ListenableFuture; import lombok.Data; import lombok.NoArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.math.NumberUtils; import org.thingsboard.common.util.DonAsynchron; import org.thingsboard.common.util.JacksonUtil; @@ -45,11 +44,8 @@ import java.util.List; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; -/** - * Created by mshvayka on 04.09.18. - */ -@Slf4j -@RuleNode(type = ComponentType.ENRICHMENT, +@RuleNode( + type = ComponentType.ENRICHMENT, name = "originator telemetry", configClazz = TbGetTelemetryNodeConfiguration.class, version = 2, @@ -58,7 +54,9 @@ import java.util.stream.Collectors; "instead of fetching just the latest telemetry or if you need to get the closest telemetry to the fetch interval start or end. " + "Also, this node can be used for telemetry aggregation within configured fetch interval.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeGetTelemetryFromDatabase") + configDirective = "tbEnrichmentNodeGetTelemetryFromDatabase", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/originator-telemetry/" +) public class TbGetTelemetryNode implements TbNode { private TbGetTelemetryNodeConfiguration config; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantAttributeNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantAttributeNode.java index 2fa065abf4..cc7327074f 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantAttributeNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantAttributeNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -29,7 +28,6 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; -@Slf4j @RuleNode( type = ComponentType.ENRICHMENT, name = "tenant attributes", @@ -39,7 +37,9 @@ import org.thingsboard.server.common.data.util.TbPair; nodeDetails = "Useful when you need to retrieve some common configuration or threshold set " + "that is stored as tenant attributes or telemetry data and use it for further message processing.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeTenantAttributesConfig") + configDirective = "tbEnrichmentNodeTenantAttributesConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/tenant-attributes/" +) public class TbGetTenantAttributeNode extends TbAbstractGetEntityDataNode { @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantDetailsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantDetailsNode.java index 4808529436..3eafb8d165 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantDetailsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantDetailsNode.java @@ -17,7 +17,6 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -30,8 +29,8 @@ import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; -@Slf4j -@RuleNode(type = ComponentType.ENRICHMENT, +@RuleNode( + type = ComponentType.ENRICHMENT, name = "tenant details", configClazz = TbGetTenantDetailsNodeConfiguration.class, version = 1, @@ -39,7 +38,9 @@ import org.thingsboard.server.common.msg.TbMsg; nodeDetails = "Useful when we need to retrieve contact information from your tenant " + "such as email, phone, address, etc., for notifications via email, SMS, and other notification providers.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeEntityDetailsConfig") + configDirective = "tbEnrichmentNodeEntityDetailsConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/tenant-details/" +) public class TbGetTenantDetailsNode extends TbAbstractGetEntityDetailsNode { private static final String TENANT_PREFIX = "tenant_"; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java index 87643ae46d..e4a1c2c1c4 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java @@ -22,7 +22,6 @@ import io.netty.handler.codec.mqtt.MqttQoS; import io.netty.handler.codec.mqtt.MqttVersion; import io.netty.handler.ssl.SslContext; import io.netty.util.concurrent.Promise; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.mqtt.MqttClient; import org.thingsboard.mqtt.MqttClientConfig; @@ -49,7 +48,6 @@ import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -@Slf4j @RuleNode( type = ComponentType.EXTERNAL, name = "mqtt", @@ -59,7 +57,8 @@ import java.util.concurrent.TimeoutException; nodeDescription = "Publish messages to the MQTT broker", nodeDetails = "Will publish message payload to the MQTT broker with QoS AT_LEAST_ONCE.", configDirective = "tbExternalNodeMqttConfig", - icon = "call_split" + icon = "call_split", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/mqtt/" ) public class TbMqttNode extends TbAbstractExternalNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java index 26c5b3fa42..34d06f7192 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java @@ -19,7 +19,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.annotations.VisibleForTesting; import io.netty.handler.codec.mqtt.MqttVersion; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.AzureIotHubUtil; import org.thingsboard.mqtt.MqttClient; import org.thingsboard.mqtt.MqttClientConfig; @@ -39,7 +38,6 @@ import org.thingsboard.server.common.data.util.TbPair; import java.time.Clock; -@Slf4j @RuleNode( type = ComponentType.EXTERNAL, name = "azure iot hub", @@ -48,7 +46,8 @@ import java.time.Clock; clusteringMode = ComponentClusteringMode.SINGLETON, nodeDescription = "Publish messages to the Azure IoT Hub", nodeDetails = "Will publish message payload to the Azure IoT Hub with QoS AT_LEAST_ONCE.", - configDirective = "tbExternalNodeAzureIotHubConfig" + configDirective = "tbExternalNodeAzureIotHubConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/azure-iot-hub/" ) public class TbAzureIotHubNode extends TbMqttNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/notification/TbNotificationNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/notification/TbNotificationNode.java index 6343191aa2..9838610d5b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/notification/TbNotificationNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/notification/TbNotificationNode.java @@ -42,7 +42,8 @@ import java.util.concurrent.ExecutionException; nodeDescription = "Sends notification to targets using the template", nodeDetails = "Will send notification to the specified targets using the template", configDirective = "tbExternalNodeNotificationConfig", - icon = "notifications" + icon = "notifications", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/send-notification/" ) public class TbNotificationNode extends TbAbstractExternalNode { @@ -51,7 +52,7 @@ public class TbNotificationNode extends TbAbstractExternalNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { super.init(ctx); - this.config = TbNodeUtils.convert(configuration, TbNotificationNodeConfiguration.class); + config = TbNodeUtils.convert(configuration, TbNotificationNodeConfiguration.class); } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/notification/TbSlackNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/notification/TbSlackNode.java index 0abbc48974..f6303a51f2 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/notification/TbSlackNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/notification/TbSlackNode.java @@ -34,7 +34,8 @@ import java.util.concurrent.ExecutionException; nodeDescription = "Send message via Slack", nodeDetails = "Sends message to a Slack channel or user", configDirective = "tbExternalNodeSlackConfig", - iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYsMTVBMiwyIDAgMCwxIDQsMTdBMiwyIDAgMCwxIDIsMTVBMiwyIDAgMCwxIDQsMTNINlYxNU03LDE1QTIsMiAwIDAsMSA5LDEzQTIsMiAwIDAsMSAxMSwxNVYyMEEyLDIgMCAwLDEgOSwyMkEyLDIgMCAwLDEgNywyMFYxNU05LDdBMiwyIDAgMCwxIDcsNUEyLDIgMCAwLDEgOSwzQTIsMiAwIDAsMSAxMSw1VjdIOU05LDhBMiwyIDAgMCwxIDExLDEwQTIsMiAwIDAsMSA5LDEySDRBMiwyIDAgMCwxIDIsMTBBMiwyIDAgMCwxIDQsOEg5TTE3LDEwQTIsMiAwIDAsMSAxOSw4QTIsMiAwIDAsMSAyMSwxMEEyLDIgMCAwLDEgMTksMTJIMTdWMTBNMTYsMTBBMiwyIDAgMCwxIDE0LDEyQTIsMiAwIDAsMSAxMiwxMFY1QTIsMiAwIDAsMSAxNCwzQTIsMiAwIDAsMSAxNiw1VjEwTTE0LDE4QTIsMiAwIDAsMSAxNiwyMEEyLDIgMCAwLDEgMTQsMjJBMiwyIDAgMCwxIDEyLDIwVjE4SDE0TTE0LDE3QTIsMiAwIDAsMSAxMiwxNUEyLDIgMCAwLDEgMTQsMTNIMTlBMiwyIDAgMCwxIDIxLDE1QTIsMiAwIDAsMSAxOSwxN0gxNFoiIC8+PC9zdmc+" + iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYsMTVBMiwyIDAgMCwxIDQsMTdBMiwyIDAgMCwxIDIsMTVBMiwyIDAgMCwxIDQsMTNINlYxNU03LDE1QTIsMiAwIDAsMSA5LDEzQTIsMiAwIDAsMSAxMSwxNVYyMEEyLDIgMCAwLDEgOSwyMkEyLDIgMCAwLDEgNywyMFYxNU05LDdBMiwyIDAgMCwxIDcsNUEyLDIgMCAwLDEgOSwzQTIsMiAwIDAsMSAxMSw1VjdIOU05LDhBMiwyIDAgMCwxIDExLDEwQTIsMiAwIDAsMSA5LDEySDRBMiwyIDAgMCwxIDIsMTBBMiwyIDAgMCwxIDQsOEg5TTE3LDEwQTIsMiAwIDAsMSAxOSw4QTIsMiAwIDAsMSAyMSwxMEEyLDIgMCAwLDEgMTksMTJIMTdWMTBNMTYsMTBBMiwyIDAgMCwxIDE0LDEyQTIsMiAwIDAsMSAxMiwxMFY1QTIsMiAwIDAsMSAxNCwzQTIsMiAwIDAsMSAxNiw1VjEwTTE0LDE4QTIsMiAwIDAsMSAxNiwyMEEyLDIgMCAwLDEgMTQsMjJBMiwyIDAgMCwxIDEyLDIwVjE4SDE0TTE0LDE3QTIsMiAwIDAsMSAxMiwxNUEyLDIgMCAwLDEgMTQsMTNIMTlBMiwyIDAgMCwxIDIxLDE1QTIsMiAwIDAsMSAxOSwxN0gxNFoiIC8+PC9zdmc+", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/send-to-slack/" ) public class TbSlackNode extends TbAbstractExternalNode { @@ -43,7 +44,7 @@ public class TbSlackNode extends TbAbstractExternalNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { super.init(ctx); - this.config = TbNodeUtils.convert(configuration, TbSlackNodeConfiguration.class); + config = TbNodeUtils.convert(configuration, TbSlackNodeConfiguration.class); } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmEvalResult.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmEvalResult.java index 6062b9ea9c..68e3c7e2e7 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmEvalResult.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmEvalResult.java @@ -15,6 +15,7 @@ */ package org.thingsboard.rule.engine.profile; +@Deprecated public enum AlarmEvalResult { FALSE, NOT_YET_TRUE, TRUE; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmRuleState.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmRuleState.java index a2a714a6df..1707f64bc7 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmRuleState.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmRuleState.java @@ -115,16 +115,12 @@ class AlarmRuleState { public AlarmEvalResult eval(DataSnapshot data) { boolean active = isActive(data, data.getTs()); - switch (spec.getType()) { - case SIMPLE: - return (active && eval(alarmRule.getCondition(), data)) ? AlarmEvalResult.TRUE : AlarmEvalResult.FALSE; - case DURATION: - return evalDuration(data, active); - case REPEATING: - return evalRepeating(data, active); - default: - return AlarmEvalResult.FALSE; - } + return switch (spec.getType()) { + case SIMPLE -> (active && eval(alarmRule.getCondition(), data)) ? + AlarmEvalResult.TRUE : AlarmEvalResult.FALSE; + case DURATION -> evalDuration(data, active); + case REPEATING -> evalRepeating(data, active); + }; } private boolean isActive(DataSnapshot data, long eventTs) { @@ -600,4 +596,5 @@ class AlarmRuleState { return null; } } + } 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 c6cc39916e..219918c485 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 @@ -48,6 +48,7 @@ import java.util.function.BiFunction; @Data @Slf4j +@Deprecated class AlarmState { public static final String ERROR_MSG = "Failed to process alarm rule for Device [%s]: %s"; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DataSnapshot.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DataSnapshot.java index 33a6fe1631..b9ca93377f 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DataSnapshot.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DataSnapshot.java @@ -26,6 +26,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +@Deprecated class DataSnapshot { private volatile boolean ready; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java index 4bd81050db..51654c4c31 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java @@ -17,6 +17,7 @@ package org.thingsboard.rule.engine.profile; import com.google.gson.JsonElement; import com.google.gson.JsonParser; +import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.TbContext; @@ -29,9 +30,14 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.device.profile.AlarmCondition; import org.thingsboard.server.common.data.device.profile.AlarmConditionFilterKey; import org.thingsboard.server.common.data.device.profile.AlarmConditionKeyType; +import org.thingsboard.server.common.data.device.profile.AlarmConditionSpec; +import org.thingsboard.server.common.data.device.profile.AlarmConditionSpecType; +import org.thingsboard.server.common.data.device.profile.AlarmRule; import org.thingsboard.server.common.data.device.profile.DeviceProfileAlarm; +import org.thingsboard.server.common.data.device.profile.DurationAlarmConditionSpec; import org.thingsboard.server.common.data.exception.ApiUsageLimitsExceededException; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; @@ -39,6 +45,8 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.query.DynamicValue; +import org.thingsboard.server.common.data.query.DynamicValueSourceType; import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.rule.RuleNodeState; @@ -56,6 +64,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; +import java.util.stream.Stream; import static org.thingsboard.server.common.data.msg.TbMsgType.ACTIVITY_EVENT; import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_ACK; @@ -71,6 +80,7 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_RE import static org.thingsboard.server.common.data.msg.TbMsgType.TIMESERIES_UPDATED; @Slf4j +@Deprecated class DeviceState { private final boolean persistState; @@ -87,6 +97,10 @@ class DeviceState { this.deviceId = deviceId; this.deviceProfile = deviceProfile; + if (hasDurationRulesWithDynamicValueFromCurrentDevice(deviceProfile)) { + latestValues = fetchLatestValues(ctx, deviceId); + } + this.dynamicPredicateValueCtx = new DynamicPredicateValueCtxImpl(ctx.getTenantId(), deviceId, ctx); if (config.isPersistAlarmRulesState()) { @@ -116,7 +130,10 @@ class DeviceState { public void updateProfile(TbContext ctx, DeviceProfile deviceProfile) throws ExecutionException, InterruptedException { Set oldKeys = Set.copyOf(this.deviceProfile.getEntityKeys()); this.deviceProfile.updateDeviceProfile(deviceProfile); - if (latestValues != null) { + + if (latestValues == null && hasDurationRulesWithDynamicValueFromCurrentDevice(this.deviceProfile)) { + latestValues = fetchLatestValues(ctx, deviceId); + } else if (latestValues != null) { Set keysToFetch = new HashSet<>(this.deviceProfile.getEntityKeys()); keysToFetch.removeAll(oldKeys); if (!keysToFetch.isEmpty()) { @@ -134,10 +151,31 @@ class DeviceState { } } + private static boolean hasDurationRulesWithDynamicValueFromCurrentDevice(ProfileState deviceProfile) { + return deviceProfile.getAlarmSettings().stream().anyMatch(DeviceState::isDurationRuleWithDynamicValueFromCurrentDevice); + } + + private static boolean isDurationRuleWithDynamicValueFromCurrentDevice(DeviceProfileAlarm alarm) { + return Stream.concat(alarm.getCreateRules().values().stream(), Stream.ofNullable(alarm.getClearRule())) + .map(AlarmRule::getCondition) + .map(AlarmCondition::getSpec) + .anyMatch(spec -> isDurationRule(spec) && hasDynamicDurationValueFromCurrentDevice((DurationAlarmConditionSpec) spec)); + } + + private static boolean isDurationRule(AlarmConditionSpec spec) { + return spec instanceof DurationAlarmConditionSpec durationSpec && durationSpec.getType() == AlarmConditionSpecType.DURATION; + } + + private static boolean hasDynamicDurationValueFromCurrentDevice(DurationAlarmConditionSpec spec) { + DynamicValue dynamicValue = spec.getPredicate().getDynamicValue(); + return dynamicValue != null && dynamicValue.getSourceType() == DynamicValueSourceType.CURRENT_DEVICE; + } + public void harvestAlarms(TbContext ctx, long ts) throws ExecutionException, InterruptedException { log.debug("[{}] Going to harvest alarms: {}", ctx.getSelfId(), ts); boolean stateChanged = false; for (AlarmState state : alarmStates.values()) { + state.setDataSnapshot(latestValues); stateChanged |= state.process(ctx, ts); } if (persistState && stateChanged) { @@ -347,7 +385,8 @@ class DeviceState { return EntityKeyType.ATTRIBUTE; } - private DataSnapshot fetchLatestValues(TbContext ctx, EntityId originator) throws ExecutionException, InterruptedException { + @SneakyThrows + private DataSnapshot fetchLatestValues(TbContext ctx, EntityId originator) { Set entityKeysToFetch = deviceProfile.getEntityKeys(); DataSnapshot result = new DataSnapshot(entityKeysToFetch); addEntityKeysToSnapshot(ctx, originator, entityKeysToFetch, result); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DynamicPredicateValueCtx.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DynamicPredicateValueCtx.java index 3c2884d616..f6cdc9a781 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DynamicPredicateValueCtx.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DynamicPredicateValueCtx.java @@ -15,6 +15,7 @@ */ package org.thingsboard.rule.engine.profile; +@Deprecated public interface DynamicPredicateValueCtx { EntityKeyValue getTenantValue(String key); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DynamicPredicateValueCtxImpl.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DynamicPredicateValueCtxImpl.java index 18fa81f63a..8962dff463 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DynamicPredicateValueCtxImpl.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DynamicPredicateValueCtxImpl.java @@ -29,6 +29,7 @@ import java.util.Optional; import java.util.concurrent.ExecutionException; @Slf4j +@Deprecated public class DynamicPredicateValueCtxImpl implements DynamicPredicateValueCtx { private final TenantId tenantId; private CustomerId customerId; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/EntityKeyValue.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/EntityKeyValue.java index 7561c51386..9742ffa234 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/EntityKeyValue.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/EntityKeyValue.java @@ -20,6 +20,7 @@ import lombok.Getter; import org.thingsboard.server.common.data.kv.DataType; @EqualsAndHashCode +@Deprecated class EntityKeyValue { @Getter diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/ProfileState.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/ProfileState.java index fffc66d02a..982a7f3b36 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/ProfileState.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/ProfileState.java @@ -45,6 +45,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +@Deprecated class ProfileState { private DeviceProfile deviceProfile; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/SnapshotUpdate.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/SnapshotUpdate.java index d41a42efa3..08af665038 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/SnapshotUpdate.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/SnapshotUpdate.java @@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.device.profile.AlarmConditionKeyType; import java.util.Set; +@Deprecated class SnapshotUpdate { @Getter diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNode.java index 9a1d04eefa..c40ac70618 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNode.java @@ -51,16 +51,18 @@ import java.util.concurrent.TimeUnit; @Slf4j @RuleNode( type = ComponentType.ACTION, - name = "device profile", + name = "device profile (deprecated)", customRelations = true, relationTypes = {"Alarm Created", "Alarm Updated", "Alarm Severity Updated", "Alarm Cleared", "Success", "Failure"}, version = 1, configClazz = TbDeviceProfileNodeConfiguration.class, - nodeDescription = "Process device messages based on device profile settings", + nodeDescription = "Process device messages based on device profile settings (deprecated)", nodeDetails = "Create and clear alarms based on alarm rules defined in device profile. The output relation type is either " + - "'Alarm Created', 'Alarm Updated', 'Alarm Severity Updated' and 'Alarm Cleared' or simply 'Success' if no alarms were affected.", - configDirective = "tbActionNodeDeviceProfileConfig" + "'Alarm Created', 'Alarm Updated', 'Alarm Severity Updated' and 'Alarm Cleared' or simply 'Success' if no alarms were affected.", + configDirective = "tbActionNodeDeviceProfileConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/device-profile/" ) +@Deprecated public class TbDeviceProfileNode implements TbNode { private TbDeviceProfileNodeConfiguration config; @@ -137,7 +139,7 @@ public class TbDeviceProfileNode implements TbNode { if (deviceState != null) { deviceState.process(ctx, msg); } else { - log.info("Device was not found! Most probably device [" + deviceId + "] has been removed from the database. Acknowledging msg."); + log.info("Device was not found! Most probably device [{}] has been removed from the database. Acknowledging msg.", deviceId); ctx.ack(msg); } } @@ -160,7 +162,7 @@ public class TbDeviceProfileNode implements TbNode { deviceStates.clear(); } - protected DeviceState getOrCreateDeviceState(TbContext ctx, DeviceId deviceId, RuleNodeState rns, boolean printNewlyAddedDeviceStates) { + private DeviceState getOrCreateDeviceState(TbContext ctx, DeviceId deviceId, RuleNodeState rns, boolean printNewlyAddedDeviceStates) { DeviceState deviceState = deviceStates.get(deviceId); if (deviceState == null) { DeviceProfile deviceProfile = cache.get(ctx.getTenantId(), deviceId); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeConfiguration.java index a3180893d1..0605eed516 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeConfiguration.java @@ -21,6 +21,7 @@ import org.thingsboard.rule.engine.api.NodeConfiguration; @Data @JsonIgnoreProperties(ignoreUnknown = true) +@Deprecated public class TbDeviceProfileNodeConfiguration implements NodeConfiguration { private boolean persistAlarmRulesState; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/state/PersistedAlarmRuleState.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/state/PersistedAlarmRuleState.java index 30aa4c443b..035d564f65 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/state/PersistedAlarmRuleState.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/state/PersistedAlarmRuleState.java @@ -22,6 +22,7 @@ import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor +@Deprecated public class PersistedAlarmRuleState { private long lastEventTs; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/state/PersistedAlarmState.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/state/PersistedAlarmState.java index dba8ba17a8..16d11485df 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/state/PersistedAlarmState.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/state/PersistedAlarmState.java @@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.alarm.AlarmSeverity; import java.util.Map; @Data +@Deprecated public class PersistedAlarmState { private Map createRuleStates; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/state/PersistedDeviceState.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/state/PersistedDeviceState.java index 46f8a3b2ca..d4307e3955 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/state/PersistedDeviceState.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/state/PersistedDeviceState.java @@ -20,6 +20,7 @@ import lombok.Data; import java.util.Map; @Data +@Deprecated public class PersistedDeviceState { Map alarmStates; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rabbitmq/TbRabbitMqNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rabbitmq/TbRabbitMqNode.java index a067bb43bd..6ae0f48658 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rabbitmq/TbRabbitMqNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rabbitmq/TbRabbitMqNode.java @@ -46,7 +46,8 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; nodeDescription = "Publish messages to the RabbitMQ", nodeDetails = "Will publish message payload to RabbitMQ queue.", configDirective = "tbExternalNodeRabbitMqConfig", - iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHZlcnNpb249IjEuMSIgeT0iMHB4IiB4PSIwcHgiIHZpZXdCb3g9IjAgMCAxMDAwIDEwMDAiPjxwYXRoIHN0cm9rZS13aWR0aD0iLjg0OTU2IiBkPSJtODYwLjQ3IDQxNi4zMmgtMjYyLjAxYy0xMi45MTMgMC0yMy42MTgtMTAuNzA0LTIzLjYxOC0yMy42MTh2LTI3Mi43MWMwLTIwLjMwNS0xNi4yMjctMzYuMjc2LTM2LjI3Ni0zNi4yNzZoLTkzLjc5MmMtMjAuMzA1IDAtMzYuMjc2IDE2LjIyNy0zNi4yNzYgMzYuMjc2djI3MC44NGMtMC4yNTQ4NyAxNC4xMDMtMTEuNDY5IDI1LjU3Mi0yNS43NDIgMjUuNTcybC04NS42MzYgMC42Nzk2NWMtMTQuMTAzIDAtMjUuNTcyLTExLjQ2OS0yNS41NzItMjUuNTcybDAuNjc5NjUtMjcxLjUyYzAtMjAuMzA1LTE2LjIyNy0zNi4yNzYtMzYuMjc2LTM2LjI3NmgtOTMuNTM3Yy0yMC4zMDUgMC0zNi4yNzYgMTYuMjI3LTM2LjI3NiAzNi4yNzZ2NzYzLjg0YzAgMTguMDk2IDE0Ljc4MiAzMi40NTMgMzIuNDUzIDMyLjQ1M2g3MjIuODFjMTguMDk2IDAgMzIuNDUzLTE0Ljc4MiAzMi40NTMtMzIuNDUzdi00MzUuMzFjLTEuMTg5NC0xOC4xODEtMTUuMjkyLTMyLjE5OC0zMy4zODgtMzIuMTk4em0tMTIyLjY4IDI4Ny4wN2MwIDIzLjYxOC0xOC44NiA0Mi40NzgtNDIuNDc4IDQyLjQ3OGgtNzMuOTk3Yy0yMy42MTggMC00Mi40NzgtMTguODYtNDIuNDc4LTQyLjQ3OHYtNzQuMjUyYzAtMjMuNjE4IDE4Ljg2LTQyLjQ3OCA0Mi40NzgtNDIuNDc4aDczLjk5N2MyMy42MTggMCA0Mi40NzggMTguODYgNDIuNDc4IDQyLjQ3OHoiLz48L3N2Zz4=" + iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHZlcnNpb249IjEuMSIgeT0iMHB4IiB4PSIwcHgiIHZpZXdCb3g9IjAgMCAxMDAwIDEwMDAiPjxwYXRoIHN0cm9rZS13aWR0aD0iLjg0OTU2IiBkPSJtODYwLjQ3IDQxNi4zMmgtMjYyLjAxYy0xMi45MTMgMC0yMy42MTgtMTAuNzA0LTIzLjYxOC0yMy42MTh2LTI3Mi43MWMwLTIwLjMwNS0xNi4yMjctMzYuMjc2LTM2LjI3Ni0zNi4yNzZoLTkzLjc5MmMtMjAuMzA1IDAtMzYuMjc2IDE2LjIyNy0zNi4yNzYgMzYuMjc2djI3MC44NGMtMC4yNTQ4NyAxNC4xMDMtMTEuNDY5IDI1LjU3Mi0yNS43NDIgMjUuNTcybC04NS42MzYgMC42Nzk2NWMtMTQuMTAzIDAtMjUuNTcyLTExLjQ2OS0yNS41NzItMjUuNTcybDAuNjc5NjUtMjcxLjUyYzAtMjAuMzA1LTE2LjIyNy0zNi4yNzYtMzYuMjc2LTM2LjI3NmgtOTMuNTM3Yy0yMC4zMDUgMC0zNi4yNzYgMTYuMjI3LTM2LjI3NiAzNi4yNzZ2NzYzLjg0YzAgMTguMDk2IDE0Ljc4MiAzMi40NTMgMzIuNDUzIDMyLjQ1M2g3MjIuODFjMTguMDk2IDAgMzIuNDUzLTE0Ljc4MiAzMi40NTMtMzIuNDUzdi00MzUuMzFjLTEuMTg5NC0xOC4xODEtMTUuMjkyLTMyLjE5OC0zMy4zODgtMzIuMTk4em0tMTIyLjY4IDI4Ny4wN2MwIDIzLjYxOC0xOC44NiA0Mi40NzgtNDIuNDc4IDQyLjQ3OGgtNzMuOTk3Yy0yMy42MTggMC00Mi40NzgtMTguODYtNDIuNDc4LTQyLjQ3OHYtNzQuMjUyYzAtMjMuNjE4IDE4Ljg2LTQyLjQ3OCA0Mi40NzgtNDIuNDc4aDczLjk5N2MyMy42MTggMCA0Mi40NzggMTguODYgNDIuNDc4IDQyLjQ3OHoiLz48L3N2Zz4=", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/rabbitmq/" ) public class TbRabbitMqNode extends TbAbstractExternalNode { @@ -99,10 +100,10 @@ public class TbRabbitMqNode extends TbAbstractExternalNode { } private ListenableFuture publishMessageAsync(TbContext ctx, TbMsg msg) { - return ctx.getExternalCallExecutor().executeAsync(() -> publishMessage(ctx, msg)); + return ctx.getExternalCallExecutor().executeAsync(() -> publishMessage(msg)); } - private TbMsg publishMessage(TbContext ctx, TbMsg msg) throws Exception { + private TbMsg publishMessage(TbMsg msg) throws Exception { String exchangeName = ""; if (!StringUtils.isEmpty(this.config.getExchangeNamePattern())) { exchangeName = TbNodeUtils.processPattern(this.config.getExchangeNamePattern(), msg); @@ -143,23 +144,15 @@ public class TbRabbitMqNode extends TbAbstractExternalNode { } static AMQP.BasicProperties convert(String name) throws TbNodeException { - switch (name) { - case "BASIC": - return MessageProperties.BASIC; - case "TEXT_PLAIN": - return MessageProperties.TEXT_PLAIN; - case "MINIMAL_BASIC": - return MessageProperties.MINIMAL_BASIC; - case "MINIMAL_PERSISTENT_BASIC": - return MessageProperties.MINIMAL_PERSISTENT_BASIC; - case "PERSISTENT_BASIC": - return MessageProperties.PERSISTENT_BASIC; - case "PERSISTENT_TEXT_PLAIN": - return MessageProperties.PERSISTENT_TEXT_PLAIN; - default: - throw new TbNodeException("Undefined message properties type '" + name + - "'! Only " + supportedPropertiesStr + " message properties types are supported!"); - } + return switch (name) { + case "BASIC" -> MessageProperties.BASIC; + case "TEXT_PLAIN" -> MessageProperties.TEXT_PLAIN; + case "MINIMAL_BASIC" -> MessageProperties.MINIMAL_BASIC; + case "MINIMAL_PERSISTENT_BASIC" -> MessageProperties.MINIMAL_PERSISTENT_BASIC; + case "PERSISTENT_BASIC" -> MessageProperties.PERSISTENT_BASIC; + case "PERSISTENT_TEXT_PLAIN" -> MessageProperties.PERSISTENT_TEXT_PLAIN; + default -> throw new TbNodeException("Undefined message properties type '" + name + "'! Only " + supportedPropertiesStr + " message properties types are supported!"); + }; } -} +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java index 8341547ef0..a2290a4eda 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java @@ -17,7 +17,6 @@ package org.thingsboard.rule.engine.rest; 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; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -30,7 +29,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.List; -@Slf4j @RuleNode( type = ComponentType.EXTERNAL, name = "rest api call", @@ -46,7 +44,8 @@ import java.util.List; "
    Note- if you use system proxy properties, the next system proxy properties should be added: \"http.proxyHost\" and \"http.proxyPort\" or \"https.proxyHost\" and \"https.proxyPort\" or \"socksProxyHost\" and \"socksProxyPort\"," + "and if your proxy with auth, the next ones should be added: \"tb.proxy.user\" and \"tb.proxy.password\" to the thingsboard.conf file.", configDirective = "tbExternalNodeRestApiCallConfig", - iconUrl = "data:image/svg+xml;base64,PHN2ZyBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCA1MTIgNTEyIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiB2ZXJzaW9uPSIxLjEiIHk9IjBweCIgeD0iMHB4Ij48ZyB0cmFuc2Zvcm09Im1hdHJpeCguOTQ5NzUgMCAwIC45NDk3NSAxNy4xMiAyNi40OTIpIj48cGF0aCBkPSJtMTY5LjExIDEwOC41NGMtOS45MDY2IDAuMDczNC0xOS4wMTQgNi41NzI0LTIyLjAxNCAxNi40NjlsLTY5Ljk5MyAyMzEuMDhjLTMuNjkwNCAxMi4xODEgMy4yODkyIDI1LjIyIDE1LjQ2OSAyOC45MSAyLjIyNTkgMC42NzQ4MSA0LjQ5NjkgMSA2LjcyODUgMSA5Ljk3MjEgMCAxOS4xNjUtNi41MTUzIDIyLjE4Mi0xNi40NjdhNi41MjI0IDYuNTIyNCAwIDAgMCAwLjAwMiAtMC4wMDJsNjkuOTktMjMxLjA3YTYuNTIyNCA2LjUyMjQgMCAwIDAgMCAtMC4wMDJjMy42ODU1LTEyLjE4MS0zLjI4Ny0yNS4yMjUtMTUuNDcxLTI4LjkxMi0yLjI4MjUtMC42OTE0NS00LjYxMTYtMS4wMTY5LTYuODk4NC0xem04NC45ODggMGMtOS45MDQ4IDAuMDczNC0xOS4wMTggNi41Njc1LTIyLjAxOCAxNi40NjlsLTY5Ljk4NiAyMzEuMDhjLTMuNjg5OCAxMi4xNzkgMy4yODUzIDI1LjIxNyAxNS40NjUgMjguOTA4IDIuMjI5NyAwLjY3NjQ3IDQuNTAwOCAxLjAwMiA2LjczMjQgMS4wMDIgOS45NzIxIDAgMTkuMTY1LTYuNTE1MyAyMi4xODItMTYuNDY3YTYuNTIyNCA2LjUyMjQgMCAwIDAgMC4wMDIgLTAuMDAybDY5Ljk4OC0yMzEuMDdjMy42OTA4LTEyLjE4MS0zLjI4NTItMjUuMjIzLTE1LjQ2Ny0yOC45MTItMi4yODE0LTAuNjkyMzEtNC42MTA4LTEuMDE4OS02Ljg5ODQtMS4wMDJ6bS0yMTcuMjkgNDIuMjNjLTEyLjcyOS0wLjAwMDg3LTIzLjE4OCAxMC40NTYtMjMuMTg4IDIzLjE4NiAwLjAwMSAxMi43MjggMTAuNDU5IDIzLjE4NiAyMy4xODggMjMuMTg2IDEyLjcyNy0wLjAwMSAyMy4xODMtMTAuNDU5IDIzLjE4NC0yMy4xODYgMC4wMDA4NzYtMTIuNzI4LTEwLjQ1Ni0yMy4xODUtMjMuMTg0LTIzLjE4NnptMCAxNDYuNjRjLTEyLjcyNy0wLjAwMDg3LTIzLjE4NiAxMC40NTUtMjMuMTg4IDIzLjE4NC0wLjAwMDg3MyAxMi43MjkgMTAuNDU4IDIzLjE4OCAyMy4xODggMjMuMTg4IDEyLjcyOC0wLjAwMSAyMy4xODQtMTAuNDYgMjMuMTg0LTIzLjE4OC0wLjAwMS0xMi43MjYtMTAuNDU3LTIzLjE4My0yMy4xODQtMjMuMTg0em0yNzAuNzkgNDIuMjExYy0xMi43MjcgMC0yMy4xODQgMTAuNDU3LTIzLjE4NCAyMy4xODRzMTAuNDU1IDIzLjE4OCAyMy4xODQgMjMuMTg4aDE1NC45OGMxMi43MjkgMCAyMy4xODYtMTAuNDYgMjMuMTg2LTIzLjE4OCAwLjAwMS0xMi43MjgtMTAuNDU4LTIzLjE4NC0yMy4xODYtMjMuMTg0eiIgdHJhbnNmb3JtPSJtYXRyaXgoMS4wMzc2IDAgMCAxLjAzNzYgLTcuNTY3NiAtMTQuOTI1KSIgc3Ryb2tlLXdpZHRoPSIxLjI2OTMiLz48L2c+PC9zdmc+" + iconUrl = "data:image/svg+xml;base64,PHN2ZyBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCA1MTIgNTEyIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiB2ZXJzaW9uPSIxLjEiIHk9IjBweCIgeD0iMHB4Ij48ZyB0cmFuc2Zvcm09Im1hdHJpeCguOTQ5NzUgMCAwIC45NDk3NSAxNy4xMiAyNi40OTIpIj48cGF0aCBkPSJtMTY5LjExIDEwOC41NGMtOS45MDY2IDAuMDczNC0xOS4wMTQgNi41NzI0LTIyLjAxNCAxNi40NjlsLTY5Ljk5MyAyMzEuMDhjLTMuNjkwNCAxMi4xODEgMy4yODkyIDI1LjIyIDE1LjQ2OSAyOC45MSAyLjIyNTkgMC42NzQ4MSA0LjQ5NjkgMSA2LjcyODUgMSA5Ljk3MjEgMCAxOS4xNjUtNi41MTUzIDIyLjE4Mi0xNi40NjdhNi41MjI0IDYuNTIyNCAwIDAgMCAwLjAwMiAtMC4wMDJsNjkuOTktMjMxLjA3YTYuNTIyNCA2LjUyMjQgMCAwIDAgMCAtMC4wMDJjMy42ODU1LTEyLjE4MS0zLjI4Ny0yNS4yMjUtMTUuNDcxLTI4LjkxMi0yLjI4MjUtMC42OTE0NS00LjYxMTYtMS4wMTY5LTYuODk4NC0xem04NC45ODggMGMtOS45MDQ4IDAuMDczNC0xOS4wMTggNi41Njc1LTIyLjAxOCAxNi40NjlsLTY5Ljk4NiAyMzEuMDhjLTMuNjg5OCAxMi4xNzkgMy4yODUzIDI1LjIxNyAxNS40NjUgMjguOTA4IDIuMjI5NyAwLjY3NjQ3IDQuNTAwOCAxLjAwMiA2LjczMjQgMS4wMDIgOS45NzIxIDAgMTkuMTY1LTYuNTE1MyAyMi4xODItMTYuNDY3YTYuNTIyNCA2LjUyMjQgMCAwIDAgMC4wMDIgLTAuMDAybDY5Ljk4OC0yMzEuMDdjMy42OTA4LTEyLjE4MS0zLjI4NTItMjUuMjIzLTE1LjQ2Ny0yOC45MTItMi4yODE0LTAuNjkyMzEtNC42MTA4LTEuMDE4OS02Ljg5ODQtMS4wMDJ6bS0yMTcuMjkgNDIuMjNjLTEyLjcyOS0wLjAwMDg3LTIzLjE4OCAxMC40NTYtMjMuMTg4IDIzLjE4NiAwLjAwMSAxMi43MjggMTAuNDU5IDIzLjE4NiAyMy4xODggMjMuMTg2IDEyLjcyNy0wLjAwMSAyMy4xODMtMTAuNDU5IDIzLjE4NC0yMy4xODYgMC4wMDA4NzYtMTIuNzI4LTEwLjQ1Ni0yMy4xODUtMjMuMTg0LTIzLjE4NnptMCAxNDYuNjRjLTEyLjcyNy0wLjAwMDg3LTIzLjE4NiAxMC40NTUtMjMuMTg4IDIzLjE4NC0wLjAwMDg3MyAxMi43MjkgMTAuNDU4IDIzLjE4OCAyMy4xODggMjMuMTg4IDEyLjcyOC0wLjAwMSAyMy4xODQtMTAuNDYgMjMuMTg0LTIzLjE4OC0wLjAwMS0xMi43MjYtMTAuNDU3LTIzLjE4My0yMy4xODQtMjMuMTg0em0yNzAuNzkgNDIuMjExYy0xMi43MjcgMC0yMy4xODQgMTAuNDU3LTIzLjE4NCAyMy4xODRzMTAuNDU1IDIzLjE4OCAyMy4xODQgMjMuMTg4aDE1NC45OGMxMi43MjkgMCAyMy4xODYtMTAuNDYgMjMuMTg2LTIzLjE4OCAwLjAwMS0xMi43MjgtMTAuNDU4LTIzLjE4NC0yMy4xODYtMjMuMTg0eiIgdHJhbnNmb3JtPSJtYXRyaXgoMS4wMzc2IDAgMCAxLjAzNzYgLTcuNTY3NiAtMTQuOTI1KSIgc3Ryb2tlLXdpZHRoPSIxLjI2OTMiLz48L2c+PC9zdmc+", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/rest-api-call/" ) public class TbRestApiCallNode extends TbAbstractExternalNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNode.java index 5be2768a0f..8148e5ea58 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.rest; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; @@ -28,7 +27,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.UUID; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "rest call reply", @@ -36,7 +34,8 @@ import java.util.UUID; nodeDescription = "Sends reply to REST API call to rule engine", nodeDetails = "Expects messages with any message type. Forwards incoming message as a reply to REST API call sent to rule engine.", configDirective = "tbActionNodeSendRestApiCallReplyConfig", - icon = "call_merge" + icon = "call_merge", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/rest-call-reply/" ) public class TbSendRestApiCallReplyNode implements TbNode { @@ -44,7 +43,7 @@ public class TbSendRestApiCallReplyNode implements TbNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbSendRestApiCallReplyNodeConfiguration.class); + config = TbNodeUtils.convert(configuration, TbSendRestApiCallReplyNodeConfiguration.class); } @Override @@ -62,4 +61,5 @@ public class TbSendRestApiCallReplyNode implements TbNode { ctx.tellSuccess(msg); } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCReplyNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCReplyNode.java index a8e7142cfb..0f136772d4 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCReplyNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCReplyNode.java @@ -19,7 +19,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.nullness.qual.NonNull; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -41,7 +41,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.UUID; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "rpc call reply", @@ -49,7 +48,8 @@ import java.util.UUID; nodeDescription = "Sends reply to RPC call from device", nodeDetails = "Expects messages with any message type. Will forward message body to the device.", configDirective = "tbActionNodeRpcReplyConfig", - icon = "call_merge" + icon = "call_merge", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/rpc-call-reply/" ) public class TbSendRPCReplyNode implements TbNode { @@ -57,7 +57,7 @@ public class TbSendRPCReplyNode implements TbNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbSendRpcReplyNodeConfiguration.class); + config = TbNodeUtils.convert(configuration, TbSendRpcReplyNodeConfiguration.class); } @Override @@ -103,7 +103,7 @@ public class TbSendRPCReplyNode implements TbNode { body.put("requestId", requestIdStr); body.put("response", msg.getData()); EdgeEvent edgeEvent = EdgeUtils.constructEdgeEvent(ctx.getTenantId(), edgeId, EdgeEventType.DEVICE, - EdgeEventActionType.RPC_CALL, deviceId, JacksonUtil.valueToTree(body)); + EdgeEventActionType.RPC_CALL, deviceId, JacksonUtil.valueToTree(body)); ListenableFuture future = ctx.getEdgeEventService().saveAsync(edgeEvent); Futures.addCallback(future, new FutureCallback<>() { @Override @@ -112,9 +112,10 @@ public class TbSendRPCReplyNode implements TbNode { } @Override - public void onFailure(Throwable t) { + public void onFailure(@NonNull Throwable t) { ctx.tellFailure(msg, t); } }, ctx.getDbCallbackExecutor()); } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java index 51f5166653..ead79a3793 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java @@ -20,7 +20,6 @@ import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleEngineDeviceRpcRequest; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -41,7 +40,6 @@ import java.util.Random; import java.util.UUID; import java.util.concurrent.TimeUnit; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "rpc call request", @@ -50,17 +48,18 @@ import java.util.concurrent.TimeUnit; nodeDetails = "Expects messages with \"method\" and \"params\". Will forward response from device to next nodes." + "If the RPC call request is originated by REST API call from user, will forward the response to user immediately.", configDirective = "tbActionNodeRpcRequestConfig", - icon = "call_made" + icon = "call_made", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/rpc-call-request/" ) public class TbSendRPCRequestNode implements TbNode { - private Random random = new Random(); - private Gson gson = new Gson(); + private final Random random = new Random(); + private final Gson gson = new Gson(); private TbSendRpcRequestNodeConfiguration config; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbSendRpcRequestNodeConfiguration.class); + config = TbNodeUtils.convert(configuration, TbSendRpcRequestNodeConfiguration.class); } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/sms/TbSendSmsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/sms/TbSendSmsNode.java index d5583037ac..3033c9d382 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/sms/TbSendSmsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/sms/TbSendSmsNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.sms; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -28,7 +27,6 @@ import org.thingsboard.server.common.msg.TbMsg; import static org.thingsboard.common.util.DonAsynchron.withCallback; -@Slf4j @RuleNode( type = ComponentType.EXTERNAL, name = "send sms", @@ -36,7 +34,8 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; nodeDescription = "Sends SMS message via SMS provider.", nodeDetails = "Will send SMS message by populating target phone numbers and sms message fields using values derived from message metadata.", configDirective = "tbExternalNodeSendSmsConfig", - icon = "sms" + icon = "sms", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/send-sms/" ) public class TbSendSmsNode extends TbAbstractExternalNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbCalculatedFieldsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbCalculatedFieldsNode.java index e703e9dd25..9d91802f8b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbCalculatedFieldsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbCalculatedFieldsNode.java @@ -16,16 +16,13 @@ package org.thingsboard.rule.engine.telemetry; import com.google.gson.JsonParser; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; -import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; -import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.kv.AttributeKvEntry; @@ -41,27 +38,23 @@ import java.util.Map; import static org.thingsboard.server.common.data.DataConstants.SCOPE; -@Slf4j @RuleNode( type = ComponentType.ACTION, - name = "calculated fields", + name = "calculated fields and alarm rules", configClazz = EmptyNodeConfiguration.class, - nodeDescription = "Pushes incoming messages to calculated fields service", - nodeDetails = "Node enables the processing of calculated fields without persisting incoming messages to the database. " + - "By default, the processing of calculated fields is triggered by the save attributes and save time series nodes. " + + nodeDescription = "Pushes incoming messages to calculated fields and alarm rules services", + nodeDetails = "Node enables the processing of calculated fields and alarm rules without persisting incoming messages to the database. " + + "By default, the processing of calculated fields and alarm rules is triggered by the save attributes and save time series nodes. " + "This rule node accepts the same messages as these nodes but allows you to trigger the processing of calculated " + - "fields independently, ensuring that derived data can be computed and utilized in real time without storing the original message in the database.", + "fields or alarm rules independently, ensuring that derived data can be computed and utilized in real time without storing the original message in the database.", configDirective = "tbNodeEmptyConfig", - icon = "published_with_changes" + icon = "published_with_changes", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/calculated-fields/" ) public class TbCalculatedFieldsNode implements TbNode { - private EmptyNodeConfiguration config; - @Override - public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, EmptyNodeConfiguration.class); - } + public void init(TbContext ctx, TbNodeConfiguration configuration) {} @Override public void onMsg(TbContext ctx, TbMsg msg) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java index 280d8de824..533f7d13dd 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java @@ -21,7 +21,6 @@ import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import com.google.gson.JsonParser; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.DonAsynchron; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.AttributesSaveRequest; @@ -56,7 +55,6 @@ import static org.thingsboard.server.common.data.DataConstants.NOTIFY_DEVICE_MET import static org.thingsboard.server.common.data.DataConstants.SCOPE; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_REQUEST; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "save attributes", @@ -107,7 +105,8 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_R Output connections: Success, Failure. """, configDirective = "tbActionNodeAttributesConfig", - icon = "file_upload" + icon = "file_upload", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/save-attributes/" ) public class TbMsgAttributesNode implements TbNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgDeleteAttributesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgDeleteAttributesNode.java index 32d700dc1d..331c28b686 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgDeleteAttributesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgDeleteAttributesNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.telemetry; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.AttributesDeleteRequest; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -35,7 +34,6 @@ import java.util.stream.Collectors; import static org.thingsboard.server.common.data.DataConstants.NOTIFY_DEVICE_METADATA_KEY; import static org.thingsboard.server.common.data.DataConstants.SCOPE; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "delete attributes", @@ -46,7 +44,8 @@ import static org.thingsboard.server.common.data.DataConstants.SCOPE; " rule node will send the \"Attributes Deleted\" event to the root chain of the message originator and " + " send the incoming message via Success chain, otherwise, Failure chain is used.", configDirective = "tbActionNodeDeleteAttributesConfig", - icon = "remove_circle" + icon = "remove_circle", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/delete-attributes/" ) public class TbMsgDeleteAttributesNode implements TbNode { @@ -55,8 +54,8 @@ public class TbMsgDeleteAttributesNode implements TbNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbMsgDeleteAttributesNodeConfiguration.class); - this.keys = config.getKeys(); + config = TbNodeUtils.convert(configuration, TbMsgDeleteAttributesNodeConfiguration.class); + keys = config.getKeys(); } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java index 8017be9a04..13dab98c54 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.telemetry; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.gson.JsonParser; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -52,7 +51,6 @@ import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessin import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.WebSocketsOnly; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_REQUEST; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "save time series", @@ -103,7 +101,8 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_RE """, configDirective = "tbActionNodeTimeseriesConfig", icon = "file_upload", - version = 1 + version = 1, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/save-timeseries/" ) public class TbMsgTimeseriesNode implements TbNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNode.java index eff08d09e7..a97cc22d5b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNode.java @@ -25,7 +25,6 @@ import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.rule.engine.util.EntitiesAlarmOriginatorIdAsyncLoader; import org.thingsboard.rule.engine.util.EntitiesByNameAndTypeLoader; -import org.thingsboard.rule.engine.util.EntitiesCustomerIdAsyncLoader; import org.thingsboard.rule.engine.util.EntitiesRelatedEntityIdAsyncLoader; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; @@ -36,6 +35,7 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.List; import java.util.NoSuchElementException; +import static com.google.common.util.concurrent.Futures.immediateFuture; import static org.thingsboard.rule.engine.transform.OriginatorSource.ENTITY; import static org.thingsboard.rule.engine.transform.OriginatorSource.RELATED; @@ -55,7 +55,8 @@ import static org.thingsboard.rule.engine.transform.OriginatorSource.RELATED; "'Device', 'Asset', 'Entity View', 'Edge' or 'User'." + "Output connections: Success, Failure.", configDirective = "tbTransformationNodeChangeOriginatorConfig", - icon = "find_replace" + icon = "find_replace", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/change-originator/" ) public class TbChangeOriginatorNode extends TbAbstractTransformNode { @@ -73,16 +74,20 @@ public class TbChangeOriginatorNode extends TbAbstractTransformNode getNewOriginator(TbContext ctx, TbMsg msg) { switch (config.getOriginatorSource()) { case CUSTOMER: - return EntitiesCustomerIdAsyncLoader.findEntityIdAsync(ctx, msg.getOriginator()); + if (msg.getOriginator().getEntityType() == EntityType.CUSTOMER) { + return immediateFuture(msg.getOriginator()); + } + return ctx.getEntityService().fetchEntityCustomerIdAsync(ctx.getTenantId(), msg.getOriginator()) + .transform(customerIdOpt -> customerIdOpt.orElse(null), ctx.getDbCallbackExecutor()); case TENANT: - return Futures.immediateFuture(ctx.getTenantId()); + return immediateFuture(ctx.getTenantId()); case RELATED: return EntitiesRelatedEntityIdAsyncLoader.findEntityAsync(ctx, msg.getOriginator(), config.getRelationsQuery()); case ALARM_ORIGINATOR: @@ -92,7 +97,7 @@ public class TbChangeOriginatorNode extends TbAbstractTransformNode
    " + "Output connections: Success, Failure.", configDirective = "tbTransformationNodeCopyKeysConfig", - icon = "content_copy" + icon = "content_copy", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/copy-key-value-pairs/" ) public class TbCopyKeysNode extends TbAbstractTransformNodeWithTbMsgSource { - private TbCopyKeysNodeConfiguration config; private TbMsgSource copyFrom; private List compiledKeyPatterns; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbCopyKeysNodeConfiguration.class); - this.copyFrom = config.getCopyFrom(); + var config = TbNodeUtils.convert(configuration, TbCopyKeysNodeConfiguration.class); + copyFrom = config.getCopyFrom(); if (copyFrom == null) { throw new TbNodeException("CopyFrom can't be null! Allowed values: " + Arrays.toString(TbMsgSource.values())); } - this.compiledKeyPatterns = config.getKeys().stream().map(Pattern::compile).collect(Collectors.toList()); + compiledKeyPatterns = config.getKeys().stream().map(Pattern::compile).collect(Collectors.toList()); } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbDeleteKeysNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbDeleteKeysNode.java index 91f16f0b3b..c2eead99fe 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbDeleteKeysNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbDeleteKeysNode.java @@ -47,22 +47,22 @@ import java.util.stream.Collectors; "keys and/or regular expressions.

    " + "Output connections: Success, Failure.", configDirective = "tbTransformationNodeDeleteKeysConfig", - icon = "remove_circle" + icon = "remove_circle", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/delete-key-value-pairs/" ) public class TbDeleteKeysNode extends TbAbstractTransformNodeWithTbMsgSource { - private TbDeleteKeysNodeConfiguration config; private TbMsgSource deleteFrom; private List compiledKeyPatterns; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbDeleteKeysNodeConfiguration.class); - this.deleteFrom = config.getDeleteFrom(); + var config = TbNodeUtils.convert(configuration, TbDeleteKeysNodeConfiguration.class); + deleteFrom = config.getDeleteFrom(); if (deleteFrom == null) { throw new TbNodeException("DeleteFrom can't be null! Allowed values: " + Arrays.toString(TbMsgSource.values())); } - this.compiledKeyPatterns = config.getKeys().stream().map(Pattern::compile).collect(Collectors.toList()); + compiledKeyPatterns = config.getKeys().stream().map(Pattern::compile).collect(Collectors.toList()); } @Override @@ -76,7 +76,7 @@ public class TbDeleteKeysNode extends TbAbstractTransformNodeWithTbMsgSource { var mdKeysToDelete = metaDataMap.keySet() .stream() .filter(this::matches) - .collect(Collectors.toList()); + .toList(); mdKeysToDelete.forEach(metaDataMap::remove); metaDataCopy = new TbMsgMetaData(metaDataMap); hasNoChanges = mdKeysToDelete.isEmpty(); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbJsonPathNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbJsonPathNode.java index eb6c0b7a68..717bdf70f2 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbJsonPathNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbJsonPathNode.java @@ -19,7 +19,6 @@ import com.jayway.jsonpath.Configuration; import com.jayway.jsonpath.JsonPath; import com.jayway.jsonpath.PathNotFoundException; import com.jayway.jsonpath.spi.json.JacksonJsonNodeJsonProvider; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -32,7 +31,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.concurrent.ExecutionException; -@Slf4j @RuleNode( type = ComponentType.TRANSFORMATION, name = "json path", @@ -41,32 +39,32 @@ import java.util.concurrent.ExecutionException; nodeDetails = "JSONPath expression specifies a path to an element or a set of elements in a JSON structure.

    " + "Output connections: Success, Failure.", icon = "functions", - configDirective = "tbTransformationNodeJsonPathConfig" + configDirective = "tbTransformationNodeJsonPathConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/json-path/" ) public class TbJsonPathNode implements TbNode { - private TbJsonPathNodeConfiguration config; private Configuration configurationJsonPath; private JsonPath jsonPath; private String jsonPathValue; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbJsonPathNodeConfiguration.class); - this.jsonPathValue = config.getJsonPath(); - if (!TbJsonPathNodeConfiguration.DEFAULT_JSON_PATH.equals(this.jsonPathValue)) { - this.configurationJsonPath = Configuration.builder() + var config = TbNodeUtils.convert(configuration, TbJsonPathNodeConfiguration.class); + jsonPathValue = config.getJsonPath(); + if (!TbJsonPathNodeConfiguration.DEFAULT_JSON_PATH.equals(jsonPathValue)) { + configurationJsonPath = Configuration.builder() .jsonProvider(new JacksonJsonNodeJsonProvider()) .build(); - this.jsonPath = JsonPath.compile(config.getJsonPath()); + jsonPath = JsonPath.compile(config.getJsonPath()); } } @Override public void onMsg(TbContext ctx, TbMsg msg) throws ExecutionException, InterruptedException, TbNodeException { - if (!TbJsonPathNodeConfiguration.DEFAULT_JSON_PATH.equals(this.jsonPathValue)) { + if (!TbJsonPathNodeConfiguration.DEFAULT_JSON_PATH.equals(jsonPathValue)) { try { - Object jsonPathData = jsonPath.read(msg.getData(), this.configurationJsonPath); + Object jsonPathData = jsonPath.read(msg.getData(), configurationJsonPath); ctx.tellSuccess(msg.transform() .data(JacksonUtil.toString(jsonPathData)) .build()); @@ -77,4 +75,5 @@ public class TbJsonPathNode implements TbNode { ctx.tellSuccess(msg); } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbRenameKeysNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbRenameKeysNode.java index f2865bfc0b..9966ff6bb4 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbRenameKeysNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbRenameKeysNode.java @@ -44,19 +44,19 @@ import java.util.concurrent.ExecutionException; "If key to rename doesn't exist in the specified source (message or message metadata) it will be ignored.

    " + "Output connections: Success, Failure.", configDirective = "tbTransformationNodeRenameKeysConfig", - icon = "find_replace" + icon = "find_replace", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/rename-keys/" ) public class TbRenameKeysNode extends TbAbstractTransformNodeWithTbMsgSource { - private TbRenameKeysNodeConfiguration config; private Map renameKeysMapping; private TbMsgSource renameIn; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbRenameKeysNodeConfiguration.class); - this.renameIn = config.getRenameIn(); - this.renameKeysMapping = config.getRenameKeysMapping(); + var config = TbNodeUtils.convert(configuration, TbRenameKeysNodeConfiguration.class); + renameIn = config.getRenameIn(); + renameKeysMapping = config.getRenameKeysMapping(); if (renameIn == null) { throw new TbNodeException("RenameIn can't be null! Allowed values: " + Arrays.toString(TbMsgSource.values())); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbSplitArrayMsgNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbSplitArrayMsgNode.java index 0eac8b073d..a1d94782fc 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbSplitArrayMsgNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbSplitArrayMsgNode.java @@ -17,7 +17,6 @@ package org.thingsboard.rule.engine.transform; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; @@ -25,7 +24,6 @@ import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; -import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; @@ -34,7 +32,6 @@ import org.thingsboard.server.common.msg.queue.TbMsgCallback; import java.util.concurrent.ExecutionException; -@Slf4j @RuleNode( type = ComponentType.TRANSFORMATION, name = "split array msg", @@ -44,16 +41,13 @@ import java.util.concurrent.ExecutionException; "All outbound messages will have the same type and metadata as the original array message.

    " + "Output connections: Success, Failure.", icon = "content_copy", - configDirective = "tbNodeEmptyConfig" + configDirective = "tbNodeEmptyConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/split-array-msg/" ) public class TbSplitArrayMsgNode implements TbNode { - private EmptyNodeConfiguration config; - @Override - public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, EmptyNodeConfiguration.class); - } + public void init(TbContext ctx, TbNodeConfiguration configuration) {} @Override public void onMsg(TbContext ctx, TbMsg msg) throws ExecutionException, InterruptedException, TbNodeException { @@ -89,4 +83,5 @@ public class TbSplitArrayMsgNode implements TbNode { ctx.tellFailure(msg, new RuntimeException("Msg data is not a JSON Array!")); } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbTransformMsgNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbTransformMsgNode.java index 9373149cf5..487d92a2b0 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbTransformMsgNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbTransformMsgNode.java @@ -41,7 +41,8 @@ import java.util.List; "{ msg: new payload,
       metadata: new metadata,
       msgType: new msgType }

    " + "All fields in resulting object are optional and will be taken from original message if not specified.

    " + "Output connections: Success, Failure.", - configDirective = "tbTransformationNodeScriptConfig" + configDirective = "tbTransformationNodeScriptConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/script/" ) public class TbTransformMsgNode extends TbAbstractTransformNode { @@ -71,4 +72,5 @@ public class TbTransformMsgNode extends TbAbstractTransformNode findEntityIdAsync(TbContext ctx, EntityId originator) { - switch (originator.getEntityType()) { - case CUSTOMER: - return Futures.immediateFuture((CustomerId) originator); - case USER: - return toCustomerIdAsync(ctx, ctx.getUserService().findUserByIdAsync(ctx.getTenantId(), (UserId) originator)); - case ASSET: - return toCustomerIdAsync(ctx, ctx.getAssetService().findAssetByIdAsync(ctx.getTenantId(), (AssetId) originator)); - case DEVICE: - return toCustomerIdAsync(ctx, Futures.immediateFuture(ctx.getDeviceService().findDeviceById(ctx.getTenantId(), (DeviceId) originator))); - default: - return Futures.immediateFailedFuture(new TbNodeException("Unexpected originator EntityType: " + originator.getEntityType())); - } - } - - private static ListenableFuture toCustomerIdAsync(TbContext ctx, ListenableFuture future) { - return Futures.transform(future, in -> in != null ? in.getCustomerId() : null, ctx.getDbCallbackExecutor()); - } - -} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java index 8ea0f70a3f..cd0b11bb25 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java @@ -18,14 +18,12 @@ package org.thingsboard.rule.engine.util; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.HasTenantId; -import org.thingsboard.server.common.data.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.ApiUsageStateId; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CalculatedFieldId; -import org.thingsboard.server.common.data.id.CalculatedFieldLinkId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.DeviceId; @@ -170,14 +168,6 @@ public class TenantIdLoader { case CALCULATED_FIELD: tenantEntity = ctx.getCalculatedFieldService().findById(ctxTenantId, new CalculatedFieldId(id)); break; - case CALCULATED_FIELD_LINK: - CalculatedFieldLink calculatedFieldLink = ctx.getCalculatedFieldService().findCalculatedFieldLinkById(ctxTenantId, new CalculatedFieldLinkId(id)); - if (calculatedFieldLink != null) { - tenantEntity = ctx.getCalculatedFieldService().findById(ctxTenantId, calculatedFieldLink.getCalculatedFieldId()); - } else { - tenantEntity = null; - } - break; case JOB: tenantEntity = ctx.getJobService().findJobById(ctxTenantId, new JobId(id)); break; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/TestDbCallbackExecutor.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/TestDbCallbackExecutor.java index b3afec500c..f27ea73ce6 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/TestDbCallbackExecutor.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/TestDbCallbackExecutor.java @@ -28,7 +28,7 @@ public class TestDbCallbackExecutor implements ListeningExecutor { try { return Futures.immediateFuture(task.call()); } catch (Exception e) { - throw new RuntimeException(e); + return Futures.immediateFailedFuture(e); } } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java index f21aa559d4..a786cdd536 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java @@ -17,8 +17,11 @@ package org.thingsboard.rule.engine.ai; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FluentFuture; +import com.google.common.util.concurrent.Futures; import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ImageContent; import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.TextContent; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.chat.request.ResponseFormat; import dev.langchain4j.model.chat.request.ResponseFormatType; @@ -32,6 +35,7 @@ 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.thingsboard.common.util.JacksonUtil; @@ -43,6 +47,10 @@ import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.server.common.data.GeneralFileDescriptor; +import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.ai.model.AiModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; @@ -52,6 +60,7 @@ import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.data.rule.RuleNode; @@ -59,9 +68,14 @@ import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; +import java.util.Base64; +import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.stream.Stream; @@ -76,16 +90,23 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; +import static org.thingsboard.server.common.data.ResourceType.GENERAL; @ExtendWith(MockitoExtension.class) class TbAiNodeTest { + private static final byte[] PNG_IMAGE = Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAC9FBMVEUAAAABAQEBAgICAgICAwMCBAQDAwMDBQUDBgYEBAQEBwcECAgFCQkFCgoGBgYGCwsGDAwHBwcHDQ0HDg4ICAgIDw8IEBAJCQkJEREKEhIKExMLFBQLFRUMFhYMFxcNDQ0NGBgNGRkODg4OGhoOGxsPDw8PHBwPHR0QEBAQHh4QHx8RERERICARISESEhISIiITExMTIyMTJCQUJSUUJiYVKCgWFhYWKSkXFxcXGhwYGBgYLC0ZGRkaMDEaMTIbGxsbMjMcMzQdNTYfOTogICAgOzwiP0AiQEEjIyMjQkMkQ0QnJycnSEkoS0wpKSkrUFErUVIsLCwvV1gvWFkwWlszMzMzYGE1NTU2NjY3Zmc4aWo5OTk5ams5a2w6Ojo6bG07bm88cXI9cnM9c3Q/dndAQEBAeHlBeXpCQkJCe3xCfH1DQ0NEREREf4FFRUVFgIJGg4VHhYdISEhIhohJh4lLi41LjI5MTExMjpBNj5FNkJJOkpRQUFBQlZdRUVFSUlJTU1NTmpxUVFRUnZ9VVVVVnqBWVlZYpadZWVlZp6laqKpbW1tbqatbqqxcXFxcrK5dra9drrBeXl5er7FfsbNfsrRgs7VhYWFiYmJiuLpjubtku71lvL5lvb9mvsBnwcNowsRpxMZpxcdra2tryctsysxubm5uzc9vb29vz9Fw0dNx0tVy1Ndy1dhz1tlz19p0dHR02Nt02dx12t1229523N93d3d33eB33uF5eXl54eR6enp64+Z65Od75eh75ul8fHx85+p86Ot96ex96u2AgICA7vGA7/KB8fSC8/aD9PeD9fiEhISE9/qF+PuF+fyGhoaG+v2G+/6Hh4eH/P+IiIiMjIyNjY2Ojo6QkJCRkZGSkpKTk5Obm5ucnJyfn5+lpaWnp6eoqKipqamqqqqwsLCzs7O1tbW4uLi5ubm6urq7u7u8vLy/v7/BwcHCwsLFxcXGxsbPz8/Y2Nji4uLj4+Pv7+/4+Pj5+fn+/v7/75T///+GLm1tAAAAAWJLR0T7omo23AAABJtJREFUeNrt3Wd8E3UYB/CH0oqm1dJaS5N0IKu0qQSVinXG4gKlKFi3uMC9FVwoVQnQqCBgBVxFnKCoFFFExFGhliWt/zoYLuIMKEpB7b3xuf9dQu+MvAjXcsTf7/PJk/ul1/S+TS53r3KkNFfk0V6evDHbFGruQ3EQTzNVUFxkHOXFB6QbIQiCIAiC/GeSs/QkR6vkCPeUaNUeSUjkkdR1npCp6a7VV7U6P1dbKfNFrS89rJNas/T6rlZtkUS/i2evhw99Q92y9/r7nVzzw7VfeDX3y2qv893plTVb1uW+uw6xiyNpspAQ8bjLy8l5REiImOlUq3Pniunyxw8Ib+vqF7aB5AgdItLVmit0iOgc9W0owhDt1RSAABL3EGeDDqmXhwRXgw6pj3qESFhtgHC1DYSGrJCQjweFq4SEqzkD67zGah8Inay+p1yl4XqKWt2lF69UDxQrzzevXZprrDn2gfTIUs85Iv/oHpny8HKHdugeVZhpXNudu6u6J1P8lmpIX1ys10X6myVfPeLl919UZFi74JXjWtfCecfa5sj+odx908XSg9Taqdaw+3I1QuYLA6RG2AbiEDpE9JJnvcYP1BRhgiw3QuoAASTuIQnP6JCF8hQlcbYBwrWIKgPDIg9UGSGP2QdCnZ+QkDneKQs4swqe1CDJ09RaXfBUETWKm3a+gFMMEMc0+0AoJVX9nM1+VDsCznLurz64b5VWq7nWLLi81QfygYZfNlU7nAUP0nOwrLnGiiAIgiAIgiAIgiDI/zstLS3tMEtKSiycgAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBYAkEQBEEQBEEQBEEQBGmrdLwuyLmhg703km8Z63k7N2Tw0jnqFt/f0bROn69WBYOfbuxiyR+8MXC9vB8QCBTQkEAgMOG2gVyvDmTzdAWuifFp077m8f503vwZr/PSd28Hg+uaTjVDlOFEIxVrINVijfwi4glCHE1XioXPz6kX9xHNFIUkvyM/xqeduIPHup95bGni8edYotOUqJCrrII0iMv4LnNFg4Sczd/9/Zw4abchD0Ygv0pIBVFZG0Nq587lu/PE02EIXSQuaSfI92l88bfNFkHqLxUnEM1+bXQEMloMY8hgn893esyQIzbzWHtveXn51GW89AtfTeyATWZIWm919s6wBtLYdfXdVCyuuEdCHhoxwr/mAzdDtMQKoaP4duQmRVG+kUtyu83X3OuylX09f+9r0c6eOvkjx82fdPdLiHrdjsrD1Z39LP5W06ExQ475g8eqSR6PZ+oXvLSVNWk/nmmGKNcSXaBYBXEPFkMXV1GlhFyYlSof3t19ZOxfPJp+4/HTeh47JhGdqLQxJDtpyRJxBgUi+0g7QkYSlVsHoVtFrcNiyO0SsoXHDxIykej4v/8F+XxDKLRxmXWQfo2jyGJIh894PDs9FArNeIGXvlwbCn37Upl5rXObOMPtf1K4z5u8ne/sx0tl6hbfgtNkBEGQPZs4uUBwTxoTH5DxtM0TD46+20lpHrfXX7e52/jtyj9kFKbIT2L3FQAAAABJRU5ErkJggg=="); + @Mock TbContext ctxMock; @Mock AiModelService aiModelServiceMock; @Mock RuleEngineAiChatModelService aiChatModelServiceMock; + @Mock + TbResourceDataCache tbResourceDataCacheMock; + @Mock + ResourceService resourceServiceMock; TbAiNode aiNode; TbAiNodeConfiguration config; @@ -108,7 +129,10 @@ class TbAiNodeTest { config = new TbAiNodeConfiguration(); modelConfig = OpenAiChatModelConfig.builder() - .providerConfig(new OpenAiProviderConfig("test-api-key")) + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL) + .apiKey("test-api-key") + .build()) .modelId("gpt-4o") .temperature(0.5) .topP(0.3) @@ -141,6 +165,8 @@ class TbAiNodeTest { lenient().when(ctxMock.getAiModelService()).thenReturn(aiModelServiceMock); lenient().when(ctxMock.getAiChatModelService()).thenReturn(aiChatModelServiceMock); lenient().when(ctxMock.getDbCallbackExecutor()).thenReturn(new TestDbCallbackExecutor()); + lenient().when(ctxMock.getTbResourceDataCache()).thenReturn(tbResourceDataCacheMock); + lenient().when(ctxMock.getResourceService()).thenReturn(resourceServiceMock); } @Test @@ -158,6 +184,7 @@ class TbAiNodeTest { assertThat(config.getResponseFormat()).isEqualTo(new TbJsonResponseFormat()); assertThat(config.getTimeoutSeconds()).isEqualTo(60); assertThat(config.isForceAck()).isTrue(); + assertThat(config.getResourceIds()).isNull(); } /* -- Node initialization tests -- */ @@ -373,6 +400,36 @@ class TbAiNodeTest { .matches(e -> ((TbNodeException) e).isUnrecoverable()); } + @Test + void givenNotExistingResources_whenInit_thenThrowsException() { + // GIVEN + config = constructValidConfig(); + UUID resourceId = UUID.randomUUID(); + config.setResourceIds(Set.of(resourceId)); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasMessageContaining("[" + tenantId + "] Resource with ID: [" + resourceId + "] was not found"); + } + + @Test + void givenResourceOfWrongType_whenInit_thenThrowsException() { + // GIVEN + config = constructValidConfig(); + UUID resourceId = UUID.randomUUID(); + config.setResourceIds(Set.of(resourceId)); + + // WHEN-THEN + TbResource tbResource = new TbResource(); + tbResource.setResourceType(ResourceType.DASHBOARD); + given(resourceServiceMock.findResourceInfoById(any(), any())).willReturn(tbResource); + + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasMessageContaining("[" + tenantId + "] Resource with ID: [" + resourceId + "] has unsupported resource type: " + ResourceType.DASHBOARD); + } + /* -- Message processing tests -- */ @Test @@ -560,6 +617,166 @@ class TbAiNodeTest { ); } + @Test + void givenSystemPromptAndUserPromptAndResourcesConfigured_whenOnMsg_thenRequestContainsSystemAndUserAndResourceContent() throws TbNodeException { + String systemPrompt = "Respond with valid JSON"; + String userPrompt = "Tell me a joke"; + String textData = "Text resource content for AI request."; + String xmlData = ""; + + // GIVEN + config = constructValidConfig(); + config.setSystemPrompt(systemPrompt); + config.setUserPrompt(userPrompt); + UUID resourceId = UUID.randomUUID(); + UUID resourceId2 = UUID.randomUUID(); + UUID resourceId3 = UUID.randomUUID(); + + config.setResourceIds(Set.of(resourceId, resourceId2, resourceId3)); + + // WHEN-THEN + TbResource textResource = buildGeneralResource(textData.getBytes(), "text/plain"); + TbResource xmlResource = buildGeneralResource(xmlData.getBytes(), "application/xml"); + TbResource imageResource = buildGeneralResource(PNG_IMAGE, "image/png"); + + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(textResource); + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId2)))).willReturn(xmlResource); + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId3)))).willReturn(imageResource); + + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(textResource.toResourceDataInfo()))); + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId2)))).willReturn(FluentFuture.from(Futures.immediateFuture(xmlResource.toResourceDataInfo()))); + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId3)))).willReturn(FluentFuture.from(Futures.immediateFuture(imageResource.toResourceDataInfo()))); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync(any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.messages()).hasSize(2); + assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(systemPrompt)); + assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents()) + .containsAll(List.of(new TextContent(userPrompt), new TextContent(textData), + new TextContent(xmlData), new ImageContent(Base64.getEncoder().encodeToString(PNG_IMAGE), "image/png"))); + return true; + }) + ); + } + + @Test + void givenNullResource_whenOnMsg_thenRequestContainsSystemAndUserPrompt() throws TbNodeException { + // GIVEN + config = constructValidConfig(); + UUID resourceId = UUID.randomUUID(); + config.setResourceIds(Set.of(resourceId)); + + // WHEN-THEN + TbResource tbResource = buildGeneralResource("Text resource content for AI request.".getBytes(), "text/plain"); + + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(tbResource); + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(null))); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync(any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.messages()).hasSize(2); + assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(config.getSystemPrompt())); + assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents()) + .containsAll(List.of(new TextContent(config.getUserPrompt()))); + return true; + }) + ); + } + + @Test + void givenResourceWithNoDescriptor_whenOnMsg_thenEnqueueForTellFailure() throws TbNodeException { + // GIVEN + config = constructValidConfig(); + UUID resourceId = UUID.randomUUID(); + config.setResourceIds(Set.of(resourceId)); + + // WHEN-THEN + TbResource tbResource = buildGeneralResource("Text resource content for AI request.".getBytes(), "text/plain"); + TbResourceDataInfo resourceDataInfo = new TbResourceDataInfo(tbResource.getData(), null); + + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(tbResource); + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(resourceDataInfo))); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + var exceptionCaptor = ArgumentCaptor.forClass(Throwable.class); + then(ctxMock).should().enqueueForTellFailure(any(), exceptionCaptor.capture()); + Throwable actualException = exceptionCaptor.getValue(); + assertThat(actualException.getMessage()).isEqualTo("Missing descriptor for resource"); + } + + @Test + void givenResourceWithNoMediaType_whenOnMsg_thenEnqueueForTellFailure() throws TbNodeException { + // GIVEN + config = constructValidConfig(); + UUID resourceId = UUID.randomUUID(); + config.setResourceIds(Set.of(resourceId)); + + // WHEN-THEN + TbResource tbResource = buildGeneralResource("Text resource content for AI request.".getBytes(), "text/plain"); + TbResourceDataInfo resourceDataInfo = new TbResourceDataInfo(tbResource.getData(), JacksonUtil.newObjectNode()); + + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(tbResource); + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(resourceDataInfo))); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + var exceptionCaptor = ArgumentCaptor.forClass(Throwable.class); + then(ctxMock).should().enqueueForTellFailure(any(), exceptionCaptor.capture()); + Throwable actualException = exceptionCaptor.getValue(); + assertThat(actualException.getMessage()).isEqualTo("Missing mediaType in resource descriptor {}"); + } + @Test void givenTemplatedPrompts_whenOnMsg_thenRequestContainsSubstitutedMessages() throws TbNodeException { // GIVEN @@ -950,4 +1167,13 @@ class TbAiNodeTest { then(ctxMock).should(never()).tellFailure(any(), any()); } + private TbResource buildGeneralResource(byte[] data, String mediaType) { + TbResource tbResource = new TbResource(); + tbResource.setResourceType(GENERAL); + GeneralFileDescriptor descriptor = new GeneralFileDescriptor(mediaType); + tbResource.setDescriptorValue(descriptor); + tbResource.setData(data); + return tbResource; + } + } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/geo/GpsGeofencingActionTestCase.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/geo/GpsGeofencingActionTestCase.java index bab470f3d7..fc6af82052 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/geo/GpsGeofencingActionTestCase.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/geo/GpsGeofencingActionTestCase.java @@ -18,14 +18,14 @@ package org.thingsboard.rule.engine.geo; import lombok.Data; import org.thingsboard.server.common.data.id.EntityId; -import java.util.HashMap; -import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; @Data public class GpsGeofencingActionTestCase { private EntityId entityId; - private Map entityStates; + private ConcurrentMap entityStates; private boolean msgInside; private boolean reportPresenceStatusOnEachMessage; @@ -33,7 +33,8 @@ public class GpsGeofencingActionTestCase { this.entityId = entityId; this.msgInside = msgInside; this.reportPresenceStatusOnEachMessage = reportPresenceStatusOnEachMessage; - this.entityStates = new HashMap<>(); + this.entityStates = new ConcurrentHashMap<>(); this.entityStates.put(entityId, entityGeofencingState); } + } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNodeTest.java index 3f2c58f191..942b1ab5ce 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNodeTest.java @@ -16,7 +16,7 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; -import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.FluentFuture; import lombok.RequiredArgsConstructor; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -53,19 +53,19 @@ import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; -import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.attributes.AttributesService; -import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.timeseries.TimeseriesService; -import org.thingsboard.server.dao.user.UserService; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.NoSuchElementException; +import java.util.Optional; import java.util.UUID; +import static com.google.common.util.concurrent.Futures.immediateFuture; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -73,19 +73,20 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) public class TbGetCustomerAttributeNodeTest { - private static final DeviceId DUMMY_DEVICE_ORIGINATOR = new DeviceId(UUID.randomUUID()); - private static final TenantId TENANT_ID = new TenantId(UUID.randomUUID()); - private static final CustomerId CUSTOMER_ID = new CustomerId(UUID.randomUUID()); - private static final ListeningExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); + private final DeviceId DUMMY_DEVICE_ORIGINATOR = new DeviceId(UUID.randomUUID()); + private final TenantId TENANT_ID = TenantId.fromUUID(UUID.randomUUID()); + private final CustomerId CUSTOMER_ID = new CustomerId(UUID.randomUUID()); + private final ListeningExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); + @Mock private TbContext ctxMock; @Mock @@ -93,21 +94,24 @@ public class TbGetCustomerAttributeNodeTest { @Mock private TimeseriesService timeseriesServiceMock; @Mock - private UserService userServiceMock; - @Mock - private AssetService assetServiceMock; - @Mock - private DeviceService deviceServiceMock; + private EntityService entityServiceMock; + private TbGetCustomerAttributeNode node; private TbGetEntityDataNodeConfiguration config; private TbNodeConfiguration nodeConfiguration; private TbMsg msg; @BeforeEach - public void setUp() { + public void setup() { node = new TbGetCustomerAttributeNode(); config = new TbGetEntityDataNodeConfiguration().defaultConfiguration(); nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + + lenient().when(ctxMock.getTenantId()).thenReturn(TENANT_ID); + lenient().when(ctxMock.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR); + lenient().when(ctxMock.getAttributesService()).thenReturn(attributesServiceMock); + lenient().when(ctxMock.getTimeseriesService()).thenReturn(timeseriesServiceMock); + lenient().when(ctxMock.getEntityService()).thenReturn(entityServiceMock); } @Test @@ -237,12 +241,9 @@ public class TbGetCustomerAttributeNodeTest { .data(TbMsg.EMPTY_JSON_OBJECT) .build(); - when(ctxMock.getTenantId()).thenReturn(TENANT_ID); - - when(ctxMock.getUserService()).thenReturn(userServiceMock); - doReturn(Futures.immediateFuture(null)).when(userServiceMock).findUserByIdAsync(eq(TENANT_ID), eq(userId)); - - when(ctxMock.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR); + when(entityServiceMock.fetchEntityCustomerIdAsync(TENANT_ID, userId)).thenReturn( + FluentFuture.from(immediateFuture(Optional.empty())) + ); // WHEN node.onMsg(ctxMock, msg); @@ -252,18 +253,13 @@ public class TbGetCustomerAttributeNodeTest { var actualExceptionCaptor = ArgumentCaptor.forClass(Throwable.class); verify(ctxMock, never()).tellSuccess(any()); - verify(ctxMock, times(1)) - .tellFailure(actualMessageCaptor.capture(), actualExceptionCaptor.capture()); + verify(ctxMock).tellFailure(actualMessageCaptor.capture(), actualExceptionCaptor.capture()); var actualMessage = actualMessageCaptor.getValue(); var actualException = actualExceptionCaptor.getValue(); - var expectedExceptionMessage = String.format( - "Failed to find customer for entity with id: %s and type: %s", - userId.getId(), userId.getEntityType().getNormalName()); - assertEquals(msg, actualMessage); - assertEquals(expectedExceptionMessage, actualException.getMessage()); + assertEquals("Originator not found", actualException.getMessage()); assertInstanceOf(NoSuchElementException.class, actualException); } @@ -282,16 +278,12 @@ public class TbGetCustomerAttributeNodeTest { ); var expectedPatternProcessedKeysList = List.of("sourceKey1", "sourceKey2", "sourceKey3"); - when(ctxMock.getTenantId()).thenReturn(TENANT_ID); - - when(ctxMock.getDeviceService()).thenReturn(deviceServiceMock); - doReturn(device).when(deviceServiceMock).findDeviceById(eq(TENANT_ID), eq(device.getId())); + when(entityServiceMock.fetchEntityCustomerIdAsync(TENANT_ID, device.getId())).thenReturn( + FluentFuture.from(immediateFuture(Optional.of(CUSTOMER_ID))) + ); - when(ctxMock.getAttributesService()).thenReturn(attributesServiceMock); when(attributesServiceMock.find(eq(TENANT_ID), eq(CUSTOMER_ID), eq(AttributeScope.SERVER_SCOPE), argThat(new ListMatcher<>(expectedPatternProcessedKeysList)))) - .thenReturn(Futures.immediateFuture(attributesList)); - - when(ctxMock.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR); + .thenReturn(immediateFuture(attributesList)); // WHEN node.onMsg(ctxMock, msg); @@ -299,7 +291,7 @@ public class TbGetCustomerAttributeNodeTest { // THEN var actualMessageCaptor = ArgumentCaptor.forClass(TbMsg.class); - verify(ctxMock, times(1)).tellSuccess(actualMessageCaptor.capture()); + verify(ctxMock).tellSuccess(actualMessageCaptor.capture()); verify(ctxMock, never()).tellFailure(any(), any()); var expectedMsgData = "{\"temp\":42," + @@ -329,16 +321,12 @@ public class TbGetCustomerAttributeNodeTest { ); var expectedPatternProcessedKeysList = List.of("sourceKey1", "sourceKey2", "sourceKey3"); - when(ctxMock.getTenantId()).thenReturn(TENANT_ID); - - when(ctxMock.getUserService()).thenReturn(userServiceMock); - doReturn(Futures.immediateFuture(user)).when(userServiceMock).findUserByIdAsync(eq(TENANT_ID), eq(user.getId())); + when(entityServiceMock.fetchEntityCustomerIdAsync(TENANT_ID, user.getId())).thenReturn( + FluentFuture.from(immediateFuture(Optional.of(CUSTOMER_ID))) + ); - when(ctxMock.getAttributesService()).thenReturn(attributesServiceMock); when(attributesServiceMock.find(eq(TENANT_ID), eq(CUSTOMER_ID), eq(AttributeScope.SERVER_SCOPE), argThat(new ListMatcher<>(expectedPatternProcessedKeysList)))) - .thenReturn(Futures.immediateFuture(attributesList)); - - when(ctxMock.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR); + .thenReturn(immediateFuture(attributesList)); // WHEN node.onMsg(ctxMock, msg); @@ -346,7 +334,7 @@ public class TbGetCustomerAttributeNodeTest { // THEN var actualMessageCaptor = ArgumentCaptor.forClass(TbMsg.class); - verify(ctxMock, times(1)).tellSuccess(actualMessageCaptor.capture()); + verify(ctxMock).tellSuccess(actualMessageCaptor.capture()); verify(ctxMock, never()).tellFailure(any(), any()); var expectedMsgMetaData = new TbMsgMetaData(Map.of( @@ -375,21 +363,18 @@ public class TbGetCustomerAttributeNodeTest { ); var expectedPatternProcessedKeysList = List.of("sourceKey1", "sourceKey2", "sourceKey3"); - when(ctxMock.getTenantId()).thenReturn(TENANT_ID); - - when(ctxMock.getTimeseriesService()).thenReturn(timeseriesServiceMock); when(timeseriesServiceMock.findLatest(eq(TENANT_ID), eq(customer.getId()), argThat(new ListMatcher<>(expectedPatternProcessedKeysList)))) - .thenReturn(Futures.immediateFuture(timeseriesList)); - - when(ctxMock.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR); + .thenReturn(immediateFuture(timeseriesList)); // WHEN node.onMsg(ctxMock, msg); // THEN + verifyNoInteractions(entityServiceMock); + var actualMessageCaptor = ArgumentCaptor.forClass(TbMsg.class); - verify(ctxMock, times(1)).tellSuccess(actualMessageCaptor.capture()); + verify(ctxMock).tellSuccess(actualMessageCaptor.capture()); verify(ctxMock, never()).tellFailure(any(), any()); var expectedMsgData = "{\"temp\":42," + @@ -408,7 +393,7 @@ public class TbGetCustomerAttributeNodeTest { public void givenFetchTelemetryToMetaData_whenOnMsg_thenShouldFetchTelemetryToMetaData() { // GIVEN var asset = new Asset(new AssetId(UUID.randomUUID())); - asset.setCustomerId(new CustomerId(UUID.randomUUID())); + asset.setCustomerId(CUSTOMER_ID); prepareMsgAndConfig(TbMsgSource.METADATA, DataToFetch.LATEST_TELEMETRY, asset.getId()); @@ -419,16 +404,12 @@ public class TbGetCustomerAttributeNodeTest { ); var expectedPatternProcessedKeysList = List.of("sourceKey1", "sourceKey2", "sourceKey3"); - when(ctxMock.getTenantId()).thenReturn(TENANT_ID); - - when(ctxMock.getAssetService()).thenReturn(assetServiceMock); - doReturn(Futures.immediateFuture(asset)).when(assetServiceMock).findAssetByIdAsync(eq(TENANT_ID), eq(asset.getId())); - - when(ctxMock.getTimeseriesService()).thenReturn(timeseriesServiceMock); - when(timeseriesServiceMock.findLatest(eq(TENANT_ID), eq(asset.getCustomerId()), argThat(new ListMatcher<>(expectedPatternProcessedKeysList)))) - .thenReturn(Futures.immediateFuture(timeseriesList)); + when(entityServiceMock.fetchEntityCustomerIdAsync(TENANT_ID, asset.getId())).thenReturn( + FluentFuture.from(immediateFuture(Optional.of(CUSTOMER_ID))) + ); - when(ctxMock.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR); + when(timeseriesServiceMock.findLatest(eq(TENANT_ID), eq(CUSTOMER_ID), argThat(new ListMatcher<>(expectedPatternProcessedKeysList)))) + .thenReturn(immediateFuture(timeseriesList)); // WHEN node.onMsg(ctxMock, msg); @@ -436,7 +417,7 @@ public class TbGetCustomerAttributeNodeTest { // THEN var actualMessageCaptor = ArgumentCaptor.forClass(TbMsg.class); - verify(ctxMock, times(1)).tellSuccess(actualMessageCaptor.capture()); + verify(ctxMock).tellSuccess(actualMessageCaptor.capture()); verify(ctxMock, never()).tellFailure(any(), any()); var expectedMsgMetaData = new TbMsgMetaData(Map.of( diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java index 90c35e59b1..04e5372bf7 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java @@ -16,8 +16,8 @@ package org.thingsboard.rule.engine.profile; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.provider.Arguments; @@ -58,8 +58,11 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; -import org.thingsboard.server.common.data.kv.TsKvEntryAggWrapper; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.DynamicValue; @@ -82,19 +85,24 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Optional; -import java.util.Set; import java.util.TreeMap; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; +import static com.google.common.util.concurrent.Futures.immediateFuture; +import static java.util.Collections.emptyList; +import static java.util.Collections.singleton; +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.thingsboard.server.common.data.device.profile.AlarmConditionKeyType.ATTRIBUTE; @@ -126,16 +134,26 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { private final CustomerId customerId = new CustomerId(UUID.randomUUID()); private final DeviceProfileId deviceProfileId = new DeviceProfileId(UUID.randomUUID()); + @BeforeEach + public void setup() { + lenient().when(ctx.getTenantId()).thenReturn(tenantId); + lenient().when(ctx.getDeviceProfileCache()).thenReturn(cache); + lenient().when(ctx.getTimeseriesService()).thenReturn(timeseriesService); + lenient().when(ctx.getAlarmService()).thenReturn(alarmService); + lenient().when(ctx.getDeviceService()).thenReturn(deviceService); + lenient().when(ctx.getAttributesService()).thenReturn(attributesService); + } + @Test public void testRandomMessageType() throws Exception { init(); DeviceProfile deviceProfile = new DeviceProfile(); DeviceProfileData deviceProfileData = new DeviceProfileData(); - deviceProfileData.setAlarms(Collections.emptyList()); + deviceProfileData.setAlarms(emptyList()); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); ObjectNode data = JacksonUtil.newObjectNode(); data.put("temperature", 42); TbMsg msg = TbMsg.newMsg() @@ -156,10 +174,10 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfile deviceProfile = new DeviceProfile(); DeviceProfileData deviceProfileData = new DeviceProfileData(); - deviceProfileData.setAlarms(Collections.emptyList()); + deviceProfileData.setAlarms(emptyList()); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); ObjectNode data = JacksonUtil.newObjectNode(); data.put("temperature", 42); TbMsg msg = TbMsg.newMsg() @@ -187,20 +205,20 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); AlarmRule clearRule = new AlarmRule(); AlarmCondition clearCondition = getNumericAlarmCondition(TIME_SERIES, "temperature", LESS, 10.0); clearRule.setCondition(clearCondition); dpa.setClearRule(clearRule); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")).thenReturn(null); + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")).thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); TbMsg theMsg = TbMsg.newMsg() @@ -246,7 +264,6 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { node.onMsg(ctx, msg2); verify(ctx).tellSuccess(msg2); verify(ctx).enqueueForTellNext(theMsg2, "Alarm Updated"); - } @Test @@ -262,7 +279,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AlarmConditionFilter highTempFilter = getAlarmConditionFilter(TIME_SERIES, "temperature", GREATER, 50.0); AlarmCondition alarmHighTempCondition = new AlarmCondition(); - alarmHighTempCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmHighTempCondition.setCondition(singletonList(highTempFilter)); AlarmRule alarmHighTempRule = new AlarmRule(); alarmHighTempRule.setCondition(alarmHighTempCondition); DeviceProfileAlarm dpa = new DeviceProfileAlarm(); @@ -276,13 +293,13 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { dpa.setCreateRules(createRules); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm1")).thenReturn(null); + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm1")).thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); TbMsg theMsg = TbMsg.newMsg() @@ -366,7 +383,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { attributeKvEntity.setLastUpdateTs(System.currentTimeMillis()); AttributeKvEntry entry = attributeKvEntity.toData(); - ListenableFuture> attrListListenableFuture = Futures.immediateFuture(Collections.singletonList(entry)); + ListenableFuture> attrListListenableFuture = immediateFuture(singletonList(entry)); AlarmConditionFilter alarmEnabledFilter = new AlarmConditionFilter(); alarmEnabledFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.CONSTANT, "alarmEnabled")); @@ -396,19 +413,19 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("alarmEnabledAlarmID"); dpa.setAlarmType("alarmEnabledAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "alarmEnabledAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "alarmEnabledAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(attrListListenableFuture); TbMsg theMsg = TbMsg.newMsg() @@ -417,7 +434,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { .copyMetaData(TbMsgMetaData.EMPTY) .data(TbMsg.EMPTY_STRING) .build(); - Mockito.when(ctx.newMsg(Mockito.any(), Mockito.any(TbMsgType.class), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.anyString())) + when(ctx.newMsg(Mockito.any(), Mockito.any(TbMsgType.class), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.anyString())) .thenReturn(theMsg); ObjectNode data = JacksonUtil.newObjectNode(); @@ -459,7 +476,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { attributeKvEntity.setLastUpdateTs(System.currentTimeMillis()); AttributeKvEntry entry = attributeKvEntity.toData(); - ListenableFuture> attrListListenableFuture = Futures.immediateFuture(Optional.of(entry)); + ListenableFuture> attrListListenableFuture = immediateFuture(Optional.of(entry)); AlarmConditionFilter alarmEnabledFilter = new AlarmConditionFilter(); alarmEnabledFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.CONSTANT, "alarmEnabled")); @@ -489,24 +506,24 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("alarmEnabledAlarmID"); dpa.setAlarmType("alarmEnabledAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "alarmEnabledAlarm")) + when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device); + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "alarmEnabledAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(attributesService.find(eq(tenantId), eq(customerId), Mockito.any(AttributeScope.class), Mockito.anyString())) - .thenReturn(Futures.immediateFuture(Optional.empty())); - Mockito.when(attributesService.find(eq(tenantId), eq(tenantId), Mockito.any(AttributeScope.class), Mockito.anyString())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + .thenReturn(immediateFuture(emptyList())); + when(attributesService.find(eq(tenantId), eq(customerId), Mockito.any(AttributeScope.class), Mockito.anyString())) + .thenReturn(immediateFuture(Optional.empty())); + when(attributesService.find(eq(tenantId), eq(tenantId), Mockito.any(AttributeScope.class), Mockito.anyString())) .thenReturn(attrListListenableFuture); TbMsg theMsg = TbMsg.newMsg() @@ -554,7 +571,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entry = attributeKvEntity.toData(); ListenableFuture> listListenableFutureWithLess = - Futures.immediateFuture(Collections.singletonList(entry)); + immediateFuture(singletonList(entry)); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -568,25 +585,25 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { )); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); AlarmRule alarmRule = new AlarmRule(); alarmRule.setCondition(alarmCondition); DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFutureWithLess); TbMsg theMsg = TbMsg.newMsg() @@ -648,7 +665,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry alarmDelayAttributeKvEntry = alarmDelayAttributeKvEntity.toData(); ListenableFuture> listListenableFuture = - Futures.immediateFuture(Arrays.asList(entry, alarmDelayAttributeKvEntry)); + immediateFuture(Arrays.asList(entry, alarmDelayAttributeKvEntry)); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -662,7 +679,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { )); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); FilterPredicateValue filterPredicateValue = new FilterPredicateValue<>( 10L, @@ -680,19 +697,19 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFuture); TbMsg theMsg = TbMsg.newMsg() @@ -740,6 +757,105 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { verify(ctx, Mockito.never()).tellFailure(Mockito.any(), Mockito.any()); } + @Test + public void testCurrentDeviceAttributeForDynamicDurationValue_noMessagesReceivedFromDeviceBeforeAlarmHarvesting() throws Exception { + init(); + + // 1. Setup device profile that has no alarm rules + var deviceProfileData = new DeviceProfileData(); + deviceProfileData.setAlarms(emptyList()); + + var deviceProfile = new DeviceProfile(deviceProfileId); + deviceProfile.setTenantId(tenantId); + deviceProfile.setName("default"); + deviceProfile.setProfileData(deviceProfileData); + + given(cache.get(tenantId, deviceId)).willReturn(deviceProfile); + + // 2. Initialize device state by sending ENTITY_CREATED event + var device = new Device(deviceId); + device.setTenantId(tenantId); + device.setName("device"); + device.setDeviceProfileId(deviceProfileId); + device.setType("default"); + + var entityCreatedEvent = TbMsg.newMsg() + .type(TbMsgType.ENTITY_CREATED) + .originator(deviceId) + .data(JacksonUtil.toString(device)) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + node.onMsg(ctx, entityCreatedEvent); + + // 3. Update device profile so it now has dynamic duration rule with value taken from current device + var predicate = new NumericFilterPredicate(); + predicate.setOperation(NumericOperation.GREATER_OR_EQUAL); + predicate.setValue(new FilterPredicateValue<>(100.0)); + + var filter = new AlarmConditionFilter(); + filter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.TIME_SERIES, "temperature")); + filter.setValueType(EntityKeyValueType.NUMERIC); + filter.setPredicate(predicate); + + var durationSpec = new DurationAlarmConditionSpec(); + durationSpec.setUnit(TimeUnit.SECONDS); + durationSpec.setPredicate( + new FilterPredicateValue<>(10L, null, new DynamicValue<>(DynamicValueSourceType.CURRENT_DEVICE, "duration", false)) + ); + + var alarmCondition = new AlarmCondition(); + alarmCondition.setCondition(singletonList(filter)); + alarmCondition.setSpec(durationSpec); + + var alarmRule = new AlarmRule(); + alarmRule.setCondition(alarmCondition); + + var dpa = new DeviceProfileAlarm(); + dpa.setId("c4486528-84f2-bd72-589e-2f9a60f89c17"); + dpa.setAlarmType("Test alarm"); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + + deviceProfileData.setAlarms(singletonList(dpa)); + + // 4. Mock DB calls for keys used in alarm rule + given(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))).willReturn(immediateFuture( + List.of(new BasicTsKvEntry(123L, new DoubleDataEntry("temperature", 55.6))) + )); + + given(attributesService.find(tenantId, deviceId, AttributeScope.CLIENT_SCOPE, singleton("duration"))).willReturn(immediateFuture(emptyList())); + given(attributesService.find(tenantId, deviceId, AttributeScope.SHARED_SCOPE, singleton("duration"))).willReturn(immediateFuture(emptyList())); + given(attributesService.find(tenantId, deviceId, AttributeScope.SERVER_SCOPE, singleton("duration"))).willReturn(immediateFuture( + List.of(new BaseAttributeKvEntry(123L, new LongDataEntry("duration", 20L))) + )); + + // 5. Send DEVICE_PROFILE_UPDATE_SELF_MSG so alarm state (inside device state) gets initialized + given(cache.get(tenantId, deviceProfileId)).willReturn(deviceProfile); + + var deviceProfileUpdateMsg = TbMsg.newMsg() + .originator(tenantId) + .type(TbMsgType.DEVICE_PROFILE_UPDATE_SELF_MSG) + .data(deviceProfileId.toString()) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + node.onMsg(ctx, deviceProfileUpdateMsg); + + // 6. Not sending anything else to simulate no activity + + // 7. Simulate periodic alarm harvesting by manually sending DEVICE_PROFILE_PERIODIC_SELF_MSG message + var periodicCheck = TbMsg.newMsg() + .type(TbMsgType.DEVICE_PROFILE_PERIODIC_SELF_MSG) + .originator(tenantId) + .customerId(customerId) + .metaData(TbMsgMetaData.EMPTY) + .data(TbMsg.EMPTY_JSON_OBJECT) + .build(); + + // NPE should NOT happen here: dynamic value of duration condition should be correctly resolved + assertThatNoException().isThrownBy(() -> node.onMsg(ctx, periodicCheck)); + } + @Test public void testInheritTenantAttributeForDuration() throws Exception { init(); @@ -779,11 +895,11 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry alarmDelayAttributeKvEntry = alarmDelayAttributeKvEntity.toData(); ListenableFuture> optionalDurationAttribute = - Futures.immediateFuture(Optional.of(alarmDelayAttributeKvEntry)); + immediateFuture(Optional.of(alarmDelayAttributeKvEntry)); ListenableFuture> listNoDurationAttribute = - Futures.immediateFuture(Collections.singletonList(entry)); + immediateFuture(singletonList(entry)); ListenableFuture> emptyOptional = - Futures.immediateFuture(Optional.empty()); + immediateFuture(Optional.empty()); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -797,7 +913,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { )); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); FilterPredicateValue filterPredicateValue = new FilterPredicateValue<>( 10L, @@ -815,25 +931,25 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(tenantId), Mockito.any(AttributeScope.class), Mockito.anyString())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(tenantId), Mockito.any(AttributeScope.class), Mockito.anyString())) .thenReturn(optionalDurationAttribute); - Mockito.when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) + when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) .thenReturn(device); - Mockito.when(attributesService.find(eq(tenantId), eq(customerId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) + when(attributesService.find(eq(tenantId), eq(customerId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) .thenReturn(emptyOptional); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listNoDurationAttribute); TbMsg theMsg = TbMsg.newMsg() @@ -915,7 +1031,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry alarmDelayAttributeKvEntry = alarmDelayAttributeKvEntity.toData(); ListenableFuture> listListenableFuture = - Futures.immediateFuture(Arrays.asList(entry, alarmDelayAttributeKvEntry)); + immediateFuture(Arrays.asList(entry, alarmDelayAttributeKvEntry)); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -929,7 +1045,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { )); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); FilterPredicateValue filterPredicateValue = new FilterPredicateValue<>( 10, @@ -947,19 +1063,19 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFuture); TbMsg theMsg = TbMsg.newMsg() @@ -1039,11 +1155,11 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry alarmDelayAttributeKvEntry = alarmDelayAttributeKvEntity.toData(); ListenableFuture> optionalDurationAttribute = - Futures.immediateFuture(Optional.of(alarmDelayAttributeKvEntry)); + immediateFuture(Optional.of(alarmDelayAttributeKvEntry)); ListenableFuture> listNoDurationAttribute = - Futures.immediateFuture(Collections.singletonList(entry)); + immediateFuture(singletonList(entry)); ListenableFuture> emptyOptional = - Futures.immediateFuture(Optional.empty()); + immediateFuture(Optional.empty()); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1057,7 +1173,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { )); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); FilterPredicateValue filterPredicateValue = new FilterPredicateValue<>( 10, @@ -1074,25 +1190,25 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(tenantId), Mockito.any(AttributeScope.class), Mockito.anyString())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(tenantId), Mockito.any(AttributeScope.class), Mockito.anyString())) .thenReturn(optionalDurationAttribute); - Mockito.when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) + when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) .thenReturn(device); - Mockito.when(attributesService.find(eq(tenantId), eq(customerId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) + when(attributesService.find(eq(tenantId), eq(customerId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) .thenReturn(emptyOptional); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listNoDurationAttribute); TbMsg theMsg = TbMsg.newMsg() @@ -1160,7 +1276,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entry = attributeKvEntity.toData(); ListenableFuture> listListenableFuture = - Futures.immediateFuture(Collections.singletonList(entry)); + immediateFuture(singletonList(entry)); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1174,7 +1290,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { )); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); FilterPredicateValue filterPredicateValue = new FilterPredicateValue<>( alarmDelayInSeconds, @@ -1192,19 +1308,19 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFuture); TbMsg theMsg = TbMsg.newMsg() @@ -1277,7 +1393,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entry = attributeKvEntity.toData(); ListenableFuture> listListenableFuture = - Futures.immediateFuture(Collections.singletonList(entry)); + immediateFuture(singletonList(entry)); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1291,7 +1407,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { )); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); RepeatingAlarmConditionSpec repeating = new RepeatingAlarmConditionSpec(); repeating.setPredicate(new FilterPredicateValue<>( @@ -1306,19 +1422,19 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFuture); TbMsg theMsg = TbMsg.newMsg() @@ -1373,7 +1489,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entryActiveSchedule = attributeKvEntityActiveSchedule.toData(); ListenableFuture> listListenableFutureActiveSchedule = - Futures.immediateFuture(Collections.singletonList(entryActiveSchedule)); + immediateFuture(singletonList(entryActiveSchedule)); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1387,10 +1503,10 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { )); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); CustomTimeSchedule schedule = new CustomTimeSchedule(); - schedule.setItems(Collections.emptyList()); + schedule.setItems(emptyList()); schedule.setDynamicValue(new DynamicValue<>(DynamicValueSourceType.CURRENT_DEVICE, "dynamicValueActiveSchedule", false)); AlarmRule alarmRule = new AlarmRule(); @@ -1399,19 +1515,19 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm deviceProfileAlarmActiveSchedule = new DeviceProfileAlarm(); deviceProfileAlarmActiveSchedule.setId("highTemperatureAlarmID"); deviceProfileAlarmActiveSchedule.setAlarmType("highTemperatureAlarm"); - deviceProfileAlarmActiveSchedule.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + deviceProfileAlarmActiveSchedule.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(deviceProfileAlarmActiveSchedule)); + deviceProfileData.setAlarms(singletonList(deviceProfileAlarmActiveSchedule)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFutureActiveSchedule); TbMsg theMsg = TbMsg.newMsg() @@ -1470,7 +1586,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entryInactiveSchedule = attributeKvEntityInactiveSchedule.toData(); ListenableFuture> listListenableFutureInactiveSchedule = - Futures.immediateFuture(Collections.singletonList(entryInactiveSchedule)); + immediateFuture(singletonList(entryInactiveSchedule)); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1485,7 +1601,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); CustomTimeSchedule schedule = new CustomTimeSchedule(); @@ -1508,18 +1624,18 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm deviceProfileAlarmNonactiveSchedule = new DeviceProfileAlarm(); deviceProfileAlarmNonactiveSchedule.setId("highTemperatureAlarmID"); deviceProfileAlarmNonactiveSchedule.setAlarmType("highTemperatureAlarm"); - deviceProfileAlarmNonactiveSchedule.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + deviceProfileAlarmNonactiveSchedule.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(deviceProfileAlarmNonactiveSchedule)); + deviceProfileData.setAlarms(singletonList(deviceProfileAlarmNonactiveSchedule)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFutureInactiveSchedule); TbMsg theMsg = TbMsg.newMsg() @@ -1569,9 +1685,9 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entry = attributeKvEntity.toData(); ListenableFuture> listListenableFutureWithLess = - Futures.immediateFuture(Collections.emptyList()); + immediateFuture(emptyList()); ListenableFuture> optionalListenableFutureWithLess = - Futures.immediateFuture(Optional.of(entry)); + immediateFuture(Optional.of(entry)); AlarmConditionFilter lowTempFilter = new AlarmConditionFilter(); lowTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1586,29 +1702,29 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { ); lowTempFilter.setPredicate(lowTempPredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(lowTempFilter)); + alarmCondition.setCondition(singletonList(lowTempFilter)); AlarmRule alarmRule = new AlarmRule(); alarmRule.setCondition(alarmCondition); DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("lesstempID"); dpa.setAlarmType("lessTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "lessTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "lessTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFutureWithLess); - Mockito.when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) + when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) .thenReturn(device); - Mockito.when(attributesService.find(eq(tenantId), eq(customerId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) + when(attributesService.find(eq(tenantId), eq(customerId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) .thenReturn(optionalListenableFutureWithLess); TbMsg theMsg = TbMsg.newMsg() @@ -1655,9 +1771,9 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entry = attributeKvEntity.toData(); ListenableFuture> listListenableFutureWithLess = - Futures.immediateFuture(Collections.emptyList()); + immediateFuture(emptyList()); ListenableFuture> optionalListenableFutureWithLess = - Futures.immediateFuture(Optional.of(entry)); + immediateFuture(Optional.of(entry)); AlarmConditionFilter lowTempFilter = new AlarmConditionFilter(); lowTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1672,27 +1788,27 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { ); lowTempFilter.setPredicate(lowTempPredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(lowTempFilter)); + alarmCondition.setCondition(singletonList(lowTempFilter)); AlarmRule alarmRule = new AlarmRule(); alarmRule.setCondition(alarmCondition); DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("lesstempID"); dpa.setAlarmType("lessTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "lessTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "lessTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFutureWithLess); - Mockito.when(attributesService.find(eq(tenantId), eq(tenantId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) + when(attributesService.find(eq(tenantId), eq(tenantId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) .thenReturn(optionalListenableFutureWithLess); TbMsg theMsg = TbMsg.newMsg() @@ -1743,11 +1859,11 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entry = attributeKvEntity.toData(); ListenableFuture> listListenableFutureWithLess = - Futures.immediateFuture(Collections.emptyList()); + immediateFuture(emptyList()); ListenableFuture> emptyOptionalFuture = - Futures.immediateFuture(Optional.empty()); + immediateFuture(Optional.empty()); ListenableFuture> optionalListenableFutureWithLess = - Futures.immediateFuture(Optional.of(entry)); + immediateFuture(Optional.of(entry)); AlarmConditionFilter lowTempFilter = new AlarmConditionFilter(); lowTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1762,31 +1878,31 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { ); lowTempFilter.setPredicate(lowTempPredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(lowTempFilter)); + alarmCondition.setCondition(singletonList(lowTempFilter)); AlarmRule alarmRule = new AlarmRule(); alarmRule.setCondition(alarmCondition); DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("lesstempID"); dpa.setAlarmType("lessTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "lessTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "lessTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) .thenReturn(device); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFutureWithLess); - Mockito.when(attributesService.find(eq(tenantId), eq(customerId), Mockito.any(AttributeScope.class), Mockito.anyString())) + when(attributesService.find(eq(tenantId), eq(customerId), Mockito.any(AttributeScope.class), Mockito.anyString())) .thenReturn(emptyOptionalFuture); - Mockito.when(attributesService.find(eq(tenantId), eq(tenantId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) + when(attributesService.find(eq(tenantId), eq(tenantId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) .thenReturn(optionalListenableFutureWithLess); TbMsg theMsg = TbMsg.newMsg() @@ -1839,11 +1955,11 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entry = attributeKvEntity.toData(); ListenableFuture> listListenableFutureWithLess = - Futures.immediateFuture(Collections.emptyList()); + immediateFuture(emptyList()); ListenableFuture> emptyOptionalFuture = - Futures.immediateFuture(Optional.empty()); + immediateFuture(Optional.empty()); ListenableFuture> optionalListenableFutureWithLess = - Futures.immediateFuture(Optional.of(entry)); + immediateFuture(Optional.of(entry)); AlarmConditionFilter lowTempFilter = new AlarmConditionFilter(); lowTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1858,31 +1974,31 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { ); lowTempFilter.setPredicate(lowTempPredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(lowTempFilter)); + alarmCondition.setCondition(singletonList(lowTempFilter)); AlarmRule alarmRule = new AlarmRule(); alarmRule.setCondition(alarmCondition); DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("lesstempID"); dpa.setAlarmType("greaterTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "greaterTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "greaterTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) .thenReturn(device); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFutureWithLess); - Mockito.when(attributesService.find(eq(tenantId), eq(customerId), Mockito.any(AttributeScope.class), Mockito.anyString())) + when(attributesService.find(eq(tenantId), eq(customerId), Mockito.any(AttributeScope.class), Mockito.anyString())) .thenReturn(emptyOptionalFuture); - Mockito.when(attributesService.find(eq(tenantId), eq(tenantId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) + when(attributesService.find(eq(tenantId), eq(tenantId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) .thenReturn(optionalListenableFutureWithLess); TbMsg theMsg = TbMsg.newMsg() @@ -1911,15 +2027,12 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { } private void init() throws TbNodeException { - Mockito.when(ctx.getTenantId()).thenReturn(tenantId); - Mockito.when(ctx.getDeviceProfileCache()).thenReturn(cache); - Mockito.lenient().when(ctx.getTimeseriesService()).thenReturn(timeseriesService); - Mockito.lenient().when(ctx.getAlarmService()).thenReturn(alarmService); - Mockito.when(ctx.getDeviceService()).thenReturn(deviceService); - Mockito.lenient().when(ctx.getAttributesService()).thenReturn(attributesService); - TbNodeConfiguration nodeConfiguration = new TbNodeConfiguration(JacksonUtil.newObjectNode()); + var config = new TbDeviceProfileNodeConfiguration(); + config.setFetchAlarmRulesStateOnStart(false); + config.setPersistAlarmRulesState(false); + node = new TbDeviceProfileNode(); - node.init(ctx, nodeConfiguration); + node.init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); } private void registerCreateAlarmMock(AlarmApiCallResult a, boolean created) { @@ -1991,23 +2104,23 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, createRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, createRule))); dpa.setClearRule(clearRule); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); ListenableFuture> tsKvList = - Futures.immediateFuture(Collections.singletonList(getTsKvEntry("temperature", 35L))); + immediateFuture(singletonList(getTsKvEntry("temperature", 35L))); ListenableFuture> attrList = - Futures.immediateFuture(Collections.emptyList()); + immediateFuture(emptyList()); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) .thenReturn(tsKvList); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), any(), anySet())) + when(attributesService.find(eq(tenantId), eq(deviceId), any(), anySet())) .thenReturn(attrList); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")).thenReturn(null); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")).thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); TbMsg theMsg = TbMsg.newMsg() @@ -2046,7 +2159,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { private AlarmCondition getNumericAlarmCondition(AlarmConditionKeyType alarmConditionKeyType, String key, NumericOperation operation, Double value) { AlarmConditionFilter filter = getAlarmConditionFilter(alarmConditionKeyType, key, operation, value); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(filter)); + alarmCondition.setCondition(singletonList(filter)); return alarmCondition; } @@ -2076,13 +2189,13 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { highTemperaturePredicate.setValue(new FilterPredicateValue<>(30.0)); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); AlarmRule alarmRule = new AlarmRule(); alarmRule.setCondition(alarmCondition); DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); AlarmConditionFilter lowTempFilter = new AlarmConditionFilter(); lowTempFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.TIME_SERIES, "temperature")); @@ -2093,17 +2206,17 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { lowTempFilter.setPredicate(lowTemperaturePredicate); AlarmRule clearRule = new AlarmRule(); AlarmCondition clearCondition = new AlarmCondition(); - clearCondition.setCondition(Collections.singletonList(lowTempFilter)); + clearCondition.setCondition(singletonList(lowTempFilter)); clearRule.setCondition(clearCondition); dpa.setClearRule(clearRule); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")).thenReturn(null); + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")).thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); TbMsg theMsg = TbMsg.newMsg() diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNodeTest.java index 4826cfe297..da8816e824 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNodeTest.java @@ -15,8 +15,10 @@ */ package org.thingsboard.rule.engine.transform; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.Futures; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; @@ -35,6 +37,7 @@ 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.rule.engine.data.RelationsQuery; +import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.alarm.Alarm; @@ -54,21 +57,24 @@ import org.thingsboard.server.common.data.relation.RelationsSearchParameters; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.asset.AssetService; -import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.relation.RelationService; import java.util.Collections; import java.util.Map; import java.util.NoSuchElementException; +import java.util.Optional; import java.util.UUID; import java.util.stream.Stream; +import static com.google.common.util.concurrent.Futures.immediateFuture; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.lenient; import static org.thingsboard.rule.engine.transform.OriginatorSource.ALARM_ORIGINATOR; import static org.thingsboard.rule.engine.transform.OriginatorSource.CUSTOMER; import static org.thingsboard.rule.engine.transform.OriginatorSource.ENTITY; @@ -93,16 +99,23 @@ public class TbChangeOriginatorNodeTest { @Mock private AssetService assetServiceMock; @Mock - private DeviceService deviceServiceMock; - @Mock private RelationService relationServiceMock; @Mock private RuleEngineAlarmService alarmServiceMock; + @Mock + private EntityService entityServiceMock; @BeforeEach - public void before() throws TbNodeException { + public void setup() { node = new TbChangeOriginatorNode(); config = new TbChangeOriginatorNodeConfiguration().defaultConfiguration(); + + lenient().when(ctxMock.getTenantId()).thenReturn(TENANT_ID); + lenient().when(ctxMock.getDbCallbackExecutor()).thenReturn(dbExecutor); + lenient().when(ctxMock.getAssetService()).thenReturn(assetServiceMock); + lenient().when(ctxMock.getRelationService()).thenReturn(relationServiceMock); + lenient().when(ctxMock.getAlarmService()).thenReturn(alarmServiceMock); + lenient().when(ctxMock.getEntityService()).thenReturn(entityServiceMock); } @Test @@ -172,8 +185,13 @@ public class TbChangeOriginatorNodeTest { } @Test - public void givenOriginatorSourceIsCustomer_whenOnMsg_thenTellSuccess() throws TbNodeException { - Device device = new Device(DEVICE_ID); + @DisplayName(""" + Given a device assigned to a customer and node configured to change originator to customer, + when processing the message, + then should change message originator from device to customer""") + public void givenDeviceAssignedToCustomer_whenProcessingMessage_thenChangesOriginatorToCustomer() throws TbNodeException { + // GIVEN + var device = new Device(DEVICE_ID); device.setCustomerId(CUSTOMER_ID); TbMsg msg = TbMsg.newMsg() @@ -186,16 +204,52 @@ public class TbChangeOriginatorNodeTest { .originator(CUSTOMER_ID) .build(); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); - given(ctxMock.getDeviceService()).willReturn(deviceServiceMock); - given(ctxMock.getTenantId()).willReturn(TENANT_ID); - given(deviceServiceMock.findDeviceById(any(TenantId.class), any(DeviceId.class))).willReturn(device); + given(entityServiceMock.fetchEntityCustomerIdAsync(TENANT_ID, device.getId())).willReturn( + FluentFuture.from(immediateFuture(Optional.of(CUSTOMER_ID))) + ); + + given(ctxMock.transformMsgOriginator(any(TbMsg.class), any(EntityId.class))).willReturn(expectedMsg); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + then(ctxMock).should().transformMsgOriginator(msg, CUSTOMER_ID); + ArgumentCaptor actualMsg = ArgumentCaptor.forClass(TbMsg.class); + then(ctxMock).should().tellSuccess(actualMsg.capture()); + assertThat(actualMsg.getValue()).usingRecursiveComparison().ignoringFields("ctx").isEqualTo(expectedMsg); + } + + @Test + @DisplayName(""" + Given a customer as message originator and node configured to change originator to customer, + when processing the message, + then should keep the customer as originator""") + public void givenCustomerAsOriginator_whenProcessingMessage_thenKeepsCustomerAsOriginator() throws TbNodeException { + // GIVEN + var customer = new Customer(CUSTOMER_ID); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(customer.getId()) + .metaData(TbMsgMetaData.EMPTY) + .data(TbMsg.EMPTY_JSON_OBJECT) + .build(); + + var expectedMsg = msg.transform() + .originator(CUSTOMER_ID) + .build(); + given(ctxMock.transformMsgOriginator(any(TbMsg.class), any(EntityId.class))).willReturn(expectedMsg); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + // WHEN node.onMsg(ctxMock, msg); - then(deviceServiceMock).should().findDeviceById(TENANT_ID, DEVICE_ID); + // THEN then(ctxMock).should().transformMsgOriginator(msg, CUSTOMER_ID); ArgumentCaptor actualMsg = ArgumentCaptor.forClass(TbMsg.class); then(ctxMock).should().tellSuccess(actualMsg.capture()); @@ -216,8 +270,6 @@ public class TbChangeOriginatorNodeTest { .originator(TENANT_ID) .build(); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); - given(ctxMock.getTenantId()).willReturn(TENANT_ID); given(ctxMock.transformMsgOriginator(any(TbMsg.class), any(EntityId.class))).willReturn(expectedMsg); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); @@ -240,9 +292,6 @@ public class TbChangeOriginatorNodeTest { .data(TbMsg.EMPTY_JSON_OBJECT) .build(); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); - given(ctxMock.getRelationService()).willReturn(relationServiceMock); - given(ctxMock.getTenantId()).willReturn(TENANT_ID); given(relationServiceMock.findByQuery(any(TenantId.class), any(EntityRelationsQuery.class))).willReturn(Futures.immediateFuture(Collections.emptyList())); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); @@ -282,9 +331,6 @@ public class TbChangeOriginatorNodeTest { .originator(DEVICE_ID) .build(); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); - given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); - given(ctxMock.getTenantId()).willReturn(TENANT_ID); given(alarmServiceMock.findAlarmByIdAsync(any(TenantId.class), any(AlarmId.class))).willReturn(Futures.immediateFuture(alarm)); given(ctxMock.transformMsgOriginator(any(TbMsg.class), any(EntityId.class))).willReturn(expectedMsg); @@ -315,9 +361,6 @@ public class TbChangeOriginatorNodeTest { .originator(ASSET_ID) .build(); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); - given(ctxMock.getAssetService()).willReturn(assetServiceMock); - given(ctxMock.getTenantId()).willReturn(TENANT_ID); given(assetServiceMock.findAssetByTenantIdAndName(any(TenantId.class), any(String.class))).willReturn(new Asset(ASSET_ID)); given(ctxMock.transformMsgOriginator(any(TbMsg.class), any(EntityId.class))).willReturn(expectedMsg); @@ -355,9 +398,6 @@ public class TbChangeOriginatorNodeTest { .data(TbMsg.EMPTY_JSON_OBJECT) .build(); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); - given(ctxMock.getAssetService()).willReturn(assetServiceMock); - given(ctxMock.getTenantId()).willReturn(TENANT_ID); given(assetServiceMock.findAssetByTenantIdAndName(any(TenantId.class), any(String.class))).willReturn(null); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesCustomerIdAsyncLoaderTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesCustomerIdAsyncLoaderTest.java deleted file mode 100644 index 0be4e64ca2..0000000000 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesCustomerIdAsyncLoaderTest.java +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Copyright © 2016-2025 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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.util; - -import com.google.common.util.concurrent.Futures; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.thingsboard.common.util.ListeningExecutor; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; -import org.thingsboard.rule.engine.api.TbContext; -import org.thingsboard.rule.engine.api.TbNodeException; -import org.thingsboard.server.common.data.Customer; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.asset.Asset; -import org.thingsboard.server.common.data.id.AssetId; -import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.EntityIdFactory; -import org.thingsboard.server.common.data.id.UserId; -import org.thingsboard.server.dao.asset.AssetService; -import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.dao.user.UserService; - -import java.util.EnumSet; -import java.util.UUID; -import java.util.concurrent.ExecutionException; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -public class EntitiesCustomerIdAsyncLoaderTest { - - private static final EnumSet SUPPORTED_ENTITY_TYPES = EnumSet.of( - EntityType.CUSTOMER, - EntityType.USER, - EntityType.ASSET, - EntityType.DEVICE - ); - private static final ListeningExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); - @Mock - private TbContext ctxMock; - @Mock - private UserService userServiceMock; - @Mock - private AssetService assetServiceMock; - @Mock - private DeviceService deviceServiceMock; - - @Test - public void givenCustomerEntityType_whenFindEntityIdAsync_thenOK() throws ExecutionException, InterruptedException { - // GIVEN - var customer = new Customer(new CustomerId(UUID.randomUUID())); - - // WHEN - var actualCustomerId = EntitiesCustomerIdAsyncLoader.findEntityIdAsync(ctxMock, customer.getId()).get(); - - // THEN - assertEquals(customer.getId(), actualCustomerId); - } - - @Test - public void givenUserEntityType_whenFindEntityIdAsync_thenOK() throws ExecutionException, InterruptedException { - // GIVEN - var user = new User(new UserId(UUID.randomUUID())); - var expectedCustomerId = new CustomerId(UUID.randomUUID()); - user.setCustomerId(expectedCustomerId); - - when(ctxMock.getUserService()).thenReturn(userServiceMock); - doReturn(Futures.immediateFuture(user)).when(userServiceMock).findUserByIdAsync(any(), any()); - when(ctxMock.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR); - - // WHEN - var actualCustomerId = EntitiesCustomerIdAsyncLoader.findEntityIdAsync(ctxMock, user.getId()).get(); - - // THEN - assertEquals(expectedCustomerId, actualCustomerId); - } - - @Test - public void givenAssetEntityType_whenFindEntityIdAsync_thenOK() throws ExecutionException, InterruptedException { - // GIVEN - var asset = new Asset(new AssetId(UUID.randomUUID())); - var expectedCustomerId = new CustomerId(UUID.randomUUID()); - asset.setCustomerId(expectedCustomerId); - - when(ctxMock.getAssetService()).thenReturn(assetServiceMock); - doReturn(Futures.immediateFuture(asset)).when(assetServiceMock).findAssetByIdAsync(any(), any()); - when(ctxMock.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR); - - // WHEN - var actualCustomerId = EntitiesCustomerIdAsyncLoader.findEntityIdAsync(ctxMock, asset.getId()).get(); - - // THEN - assertEquals(expectedCustomerId, actualCustomerId); - } - - @Test - public void givenDeviceEntityType_whenFindEntityIdAsync_thenOK() throws ExecutionException, InterruptedException { - // GIVEN - var device = new Device(new DeviceId(UUID.randomUUID())); - var expectedCustomerId = new CustomerId(UUID.randomUUID()); - device.setCustomerId(expectedCustomerId); - - when(ctxMock.getDeviceService()).thenReturn(deviceServiceMock); - doReturn(device).when(deviceServiceMock).findDeviceById(any(), any()); - when(ctxMock.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR); - - // WHEN - var actualCustomerId = EntitiesCustomerIdAsyncLoader.findEntityIdAsync(ctxMock, device.getId()).get(); - - // THEN - assertEquals(expectedCustomerId, actualCustomerId); - } - - @Test - public void givenUnsupportedEntityTypes_whenFindEntityIdAsync_thenException() { - for (var entityType : EntityType.values()) { - if (!SUPPORTED_ENTITY_TYPES.contains(entityType)) { - var entityId = EntityIdFactory.getByTypeAndUuid(entityType, UUID.randomUUID()); - - var expectedExceptionMsg = "org.thingsboard.rule.engine.api.TbNodeException: Unexpected originator EntityType: " + entityType; - - var exception = assertThrows(ExecutionException.class, - () -> EntitiesCustomerIdAsyncLoader.findEntityIdAsync(ctxMock, entityId).get()); - - assertInstanceOf(TbNodeException.class, exception.getCause()); - assertEquals(expectedExceptionMsg, exception.getMessage()); - } - } - } - -} diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java index 16698b2841..15d513790d 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java @@ -29,7 +29,6 @@ import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache; import org.thingsboard.rule.engine.api.RuleEngineDeviceProfileCache; import org.thingsboard.rule.engine.api.RuleEngineRpcService; import org.thingsboard.rule.engine.api.TbContext; -import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; @@ -46,14 +45,12 @@ import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.cf.CalculatedField; -import org.thingsboard.server.common.data.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.domain.Domain; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; -import org.thingsboard.server.common.data.id.NotificationId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.job.Job; @@ -80,6 +77,7 @@ import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.domain.DomainService; import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.dao.entityview.EntityViewService; +import org.thingsboard.server.dao.job.JobService; import org.thingsboard.server.dao.mobile.MobileAppBundleService; import org.thingsboard.server.dao.mobile.MobileAppService; import org.thingsboard.server.dao.notification.NotificationRequestService; @@ -92,7 +90,6 @@ import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.rule.RuleChainService; -import org.thingsboard.server.dao.job.JobService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetsBundleService; @@ -422,12 +419,6 @@ public class TenantIdLoaderTest { when(ctx.getCalculatedFieldService()).thenReturn(calculatedFieldService); doReturn(calculatedField).when(calculatedFieldService).findById(eq(tenantId), any()); break; - case CALCULATED_FIELD_LINK: - CalculatedFieldLink calculatedFieldLink = new CalculatedFieldLink(); - calculatedFieldLink.setTenantId(tenantId); - when(ctx.getCalculatedFieldService()).thenReturn(calculatedFieldService); - doReturn(calculatedFieldLink).when(calculatedFieldService).findCalculatedFieldLinkById(eq(tenantId), any()); - break; case JOB: Job job = new Job(); job.setTenantId(tenantId); diff --git a/ui-ngx/patches/@iplab+ngx-color-picker+18.0.1.patch b/ui-ngx/patches/@iplab+ngx-color-picker+18.0.1.patch new file mode 100644 index 0000000000..c8090076cc --- /dev/null +++ b/ui-ngx/patches/@iplab+ngx-color-picker+18.0.1.patch @@ -0,0 +1,97 @@ +diff --git a/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/hex-input/hex-input.component.mjs b/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/hex-input/hex-input.component.mjs +index bf5480b..02c8246 100644 +--- a/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/hex-input/hex-input.component.mjs ++++ b/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/hex-input/hex-input.component.mjs +@@ -29,10 +29,10 @@ export class HexComponent { + } + } + static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: HexComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } +- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: HexComponent, isStandalone: true, selector: "hex-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, prefixValue: { classPropertyName: "prefixValue", publicName: "prefix", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
    \r\n \r\n @if (labelVisible()) {\r\n HEX\r\n }\r\n
    ", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } ++ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: HexComponent, isStandalone: true, selector: "hex-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, prefixValue: { classPropertyName: "prefixValue", publicName: "prefix", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
    \r\n \r\n @if (labelVisible()) {\r\n HEX\r\n }\r\n
    ", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } + } + i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: HexComponent, decorators: [{ + type: Component, +- args: [{ selector: `hex-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, template: "
    \r\n \r\n @if (labelVisible()) {\r\n HEX\r\n }\r\n
    ", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] ++ args: [{ selector: `hex-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, template: "
    \r\n \r\n @if (labelVisible()) {\r\n HEX\r\n }\r\n
    ", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] + }] }); + //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaGV4LWlucHV0LmNvbXBvbmVudC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uLy4uLy4uLy4uLy4uL3Byb2plY3RzL2lwbGFiL25neC1jb2xvci1waWNrZXIvc3JjL2xpYi9jb21wb25lbnRzL3BhcnRzL2lucHV0cy9oZXgtaW5wdXQvaGV4LWlucHV0LmNvbXBvbmVudC50cyIsIi4uLy4uLy4uLy4uLy4uLy4uLy4uLy4uL3Byb2plY3RzL2lwbGFiL25neC1jb2xvci1waWNrZXIvc3JjL2xpYi9jb21wb25lbnRzL3BhcnRzL2lucHV0cy9oZXgtaW5wdXQvaGV4LWlucHV0LmNvbXBvbmVudC5odG1sIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxTQUFTLEVBQUUsdUJBQXVCLEVBQUUsZ0JBQWdCLEVBQWUsS0FBSyxFQUFFLEtBQUssRUFBZSxNQUFNLGVBQWUsQ0FBQztBQUM3SCxPQUFPLEVBQUUsS0FBSyxFQUFFLE1BQU0saUNBQWlDLENBQUM7O0FBY3hELE1BQU0sT0FBTyxZQUFZO0lBWHpCO1FBYVcsVUFBSyxHQUF1QixLQUFLLENBQUMsUUFBUSxFQUFTLENBQUM7UUFFcEQsaUJBQVksR0FBeUIsS0FBSyxDQUFtQixLQUFLLEVBQUUsRUFBRSxLQUFLLEVBQUUsT0FBTyxFQUFFLFNBQVMsRUFBRSxnQkFBZ0IsRUFBRSxDQUFDLENBQUM7UUFFckgsZ0JBQVcsR0FBd0IsS0FBSyxDQUFTLEVBQUUsRUFBRSxFQUFFLEtBQUssRUFBRSxRQUFRLEVBQUUsQ0FBQyxDQUFDO0tBMkJwRjtJQXpCRyxJQUFXLEtBQUs7UUFDWixPQUFPLElBQUksQ0FBQyxXQUFXLEVBQUUsR0FBRyxDQUFDLElBQUksQ0FBQyxLQUFLLEVBQUUsQ0FBQyxDQUFDLENBQUMsSUFBSSxDQUFDLEtBQUssRUFBRSxDQUFDLFdBQVcsQ0FBQyxJQUFJLENBQUMsS0FBSyxFQUFFLENBQUMsT0FBTyxFQUFFLENBQUMsS0FBSyxHQUFHLENBQUMsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxHQUFHLEVBQUUsRUFBRSxDQUFDLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxDQUFDO0lBQ2xJLENBQUM7SUFFTSxhQUFhLENBQUMsS0FBb0IsRUFBRSxVQUFrQjtRQUN6RCxNQUFNLEtBQUssR0FBRyxVQUFVLENBQUMsV0FBVyxFQUFFLENBQUMsT0FBTyxDQUFDLEdBQUcsRUFBRSxFQUFFLENBQUMsQ0FBQztRQUV4RCxJQUNBLENBQUMsQ0FBQyxLQUFLLENBQUMsT0FBTyxLQUFLLEVBQUUsSUFBSSxLQUFLLENBQUMsR0FBRyxDQUFDLFdBQVcsRUFBRSxLQUFLLE9BQU8sQ0FBQyxJQUFJLEtBQUssQ0FBQyxNQUFNLEtBQUssQ0FBQyxDQUFDO2VBQ2xGLEtBQUssQ0FBQyxNQUFNLEtBQUssQ0FBQyxJQUFJLEtBQUssQ0FBQyxNQUFNLEtBQUssQ0FBQyxFQUFFLENBQUM7WUFDMUMsTUFBTSxHQUFHLEdBQUcsUUFBUSxDQUFDLEtBQUssRUFBRSxFQUFFLENBQUMsQ0FBQztZQUNoQyxNQUFNLE1BQU0sR0FBRyxHQUFHLENBQUMsUUFBUSxDQUFDLEVBQUUsQ0FBQyxDQUFDO1lBRWhDOzs7OztlQUtHO1lBQ0gsSUFBSSxNQUFNLENBQUMsUUFBUSxDQUFDLEtBQUssQ0FBQyxNQUFNLEVBQUUsR0FBRyxDQUFDLEtBQUssS0FBSyxJQUFJLElBQUksQ0FBQyxLQUFLLEtBQUssS0FBSyxFQUFFLENBQUM7Z0JBQ3ZFLE1BQU0sUUFBUSxHQUFHLElBQUksS0FBSyxDQUFDLElBQUksS0FBSyxFQUFFLENBQUMsQ0FBQztnQkFDeEMsSUFBSSxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUMsUUFBUSxDQUFDLENBQUM7WUFDN0IsQ0FBQztRQUNMLENBQUM7SUFDTCxDQUFDOzhHQWhDUSxZQUFZO2tHQUFaLFlBQVksZ2dCQ2Z6Qiw0TUFLTTs7MkZEVU8sWUFBWTtrQkFYeEIsU0FBUzsrQkFDSSxxQkFBcUIsbUJBT2QsdUJBQXVCLENBQUMsTUFBTSxjQUNuQyxJQUFJIiwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgQ29tcG9uZW50LCBDaGFuZ2VEZXRlY3Rpb25TdHJhdGVneSwgYm9vbGVhbkF0dHJpYnV0ZSwgSW5wdXRTaWduYWwsIGlucHV0LCBtb2RlbCwgTW9kZWxTaWduYWwgfSBmcm9tICdAYW5ndWxhci9jb3JlJztcclxuaW1wb3J0IHsgQ29sb3IgfSBmcm9tICcuLi8uLi8uLi8uLi9oZWxwZXJzL2NvbG9yLmNsYXNzJztcclxuXHJcblxyXG5AQ29tcG9uZW50KHtcclxuICAgIHNlbGVjdG9yOiBgaGV4LWlucHV0LWNvbXBvbmVudGAsXHJcbiAgICB0ZW1wbGF0ZVVybDogYC4vaGV4LWlucHV0LmNvbXBvbmVudC5odG1sYCxcclxuICAgIHN0eWxlVXJsczogW1xyXG4gICAgICAgIGAuLy4uLy4uL2Jhc2Uuc3R5bGUuc2Nzc2AsXHJcbiAgICAgICAgYC4vLi4vaW5wdXQuY29tcG9uZW50LnNjc3NgLFxyXG4gICAgICAgIGAuL2hleC1pbnB1dC5jb21wb25lbnQuc2Nzc2BcclxuICAgIF0sXHJcbiAgICBjaGFuZ2VEZXRlY3Rpb246IENoYW5nZURldGVjdGlvblN0cmF0ZWd5Lk9uUHVzaCxcclxuICAgIHN0YW5kYWxvbmU6IHRydWVcclxufSlcclxuZXhwb3J0IGNsYXNzIEhleENvbXBvbmVudCB7XHJcblxyXG4gICAgcHVibGljIGNvbG9yOiBNb2RlbFNpZ25hbDxDb2xvcj4gPSBtb2RlbC5yZXF1aXJlZDxDb2xvcj4oKTtcclxuXHJcbiAgICBwdWJsaWMgbGFiZWxWaXNpYmxlOiBJbnB1dFNpZ25hbDxib29sZWFuPiA9IGlucHV0PGJvb2xlYW4sIGJvb2xlYW4+KGZhbHNlLCB7IGFsaWFzOiAnbGFiZWwnLCB0cmFuc2Zvcm06IGJvb2xlYW5BdHRyaWJ1dGUgfSk7XHJcblxyXG4gICAgcHVibGljIHByZWZpeFZhbHVlOiBJbnB1dFNpZ25hbDxzdHJpbmc+ID0gaW5wdXQ8c3RyaW5nPignJywgeyBhbGlhczogJ3ByZWZpeCcgfSk7XHJcblxyXG4gICAgcHVibGljIGdldCB2YWx1ZSgpIHtcclxuICAgICAgICByZXR1cm4gdGhpcy5wcmVmaXhWYWx1ZSgpICsgKHRoaXMuY29sb3IoKSA/IHRoaXMuY29sb3IoKS50b0hleFN0cmluZyh0aGlzLmNvbG9yKCkuZ2V0UmdiYSgpLmFscGhhIDwgMSkucmVwbGFjZSgnIycsICcnKSA6ICcnKTtcclxuICAgIH1cclxuXHJcbiAgICBwdWJsaWMgb25JbnB1dENoYW5nZShldmVudDogS2V5Ym9hcmRFdmVudCwgaW5wdXRWYWx1ZTogc3RyaW5nKTogdm9pZCB7XHJcbiAgICAgICAgY29uc3QgdmFsdWUgPSBpbnB1dFZhbHVlLnRvTG93ZXJDYXNlKCkucmVwbGFjZSgnIycsICcnKTtcclxuXHJcbiAgICAgICAgaWYgKFxyXG4gICAgICAgICgoZXZlbnQua2V5Q29kZSA9PT0gMTMgfHwgZXZlbnQua2V5LnRvTG93ZXJDYXNlKCkgPT09ICdlbnRlcicpICYmIHZhbHVlLmxlbmd0aCA9PT0gMylcclxuICAgICAgICB8fCB2YWx1ZS5sZW5ndGggPT09IDYgfHwgdmFsdWUubGVuZ3RoID09PSA4KSB7XHJcbiAgICAgICAgICAgIGNvbnN0IGhleCA9IHBhcnNlSW50KHZhbHVlLCAxNik7XHJcbiAgICAgICAgICAgIGNvbnN0IGhleFN0ciA9IGhleC50b1N0cmluZygxNik7XHJcblxyXG4gICAgICAgICAgICAvKipcclxuICAgICAgICAgICAgICogaWYgdmFsdWUgaXMgdmFsaWRcclxuICAgICAgICAgICAgICogY2hhbmdlIGNvbG9yIGVsc2UgZG8gbm90aGluZ1xyXG4gICAgICAgICAgICAgKiBhZnRlciBwYXJzaW5nIG51bWJlciBsZWFkaW5nIDAgaXMgcmVtb3ZlZCxcclxuICAgICAgICAgICAgICogY29tcGFyZSBsZW5ndGggYW5kIGFkZCBsZWFkaW5nIDAgYmVmb3JlIGNvbXBhcmluZyB0d28gdmFsdWVzXHJcbiAgICAgICAgICAgICAqL1xyXG4gICAgICAgICAgICBpZiAoaGV4U3RyLnBhZFN0YXJ0KHZhbHVlLmxlbmd0aCwgJzAnKSA9PT0gdmFsdWUgJiYgdGhpcy52YWx1ZSAhPT0gdmFsdWUpIHtcclxuICAgICAgICAgICAgICAgIGNvbnN0IG5ld0NvbG9yID0gbmV3IENvbG9yKGAjJHt2YWx1ZX1gKTtcclxuICAgICAgICAgICAgICAgIHRoaXMuY29sb3Iuc2V0KG5ld0NvbG9yKTtcclxuICAgICAgICAgICAgfVxyXG4gICAgICAgIH1cclxuICAgIH1cclxufVxyXG4iLCI8ZGl2IGNsYXNzPVwiY29sdW1uXCI+XHJcbiAgICA8aW5wdXQgI2VsUmVmIHR5cGU9XCJ0ZXh0XCIgW3ZhbHVlXT1cInZhbHVlXCIgKGtleXVwKT1cIm9uSW5wdXRDaGFuZ2UoJGV2ZW50LCBlbFJlZi52YWx1ZSlcIiAvPlxyXG4gICAgQGlmIChsYWJlbFZpc2libGUoKSkge1xyXG4gICAgICAgIDxzcGFuPkhFWDwvc3Bhbj5cclxuICAgIH1cclxuPC9kaXY+Il19 +diff --git a/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/hsla-input/hsla-input.component.mjs b/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/hsla-input/hsla-input.component.mjs +index 2454b27..dd007ce 100644 +--- a/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/hsla-input/hsla-input.component.mjs ++++ b/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/hsla-input/hsla-input.component.mjs +@@ -21,10 +21,10 @@ export class HslaComponent { + this.color.set(newColor); + } + static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: HslaComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } +- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: HslaComponent, isStandalone: true, selector: "hsla-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
    \r\n \r\n @if (labelVisible()) {\r\n H\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n S\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n L\r\n }\r\n
    \r\n@if (isAlphaVisible()) {\r\n
    \r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
    \r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } ++ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: HslaComponent, isStandalone: true, selector: "hsla-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
    \r\n \r\n @if (labelVisible()) {\r\n H\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n S\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n L\r\n }\r\n
    \r\n@if (isAlphaVisible()) {\r\n
    \r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
    \r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } + } + i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: HslaComponent, decorators: [{ + type: Component, +- args: [{ selector: `hsla-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ColorPickerInputDirective], template: "
    \r\n \r\n @if (labelVisible()) {\r\n H\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n S\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n L\r\n }\r\n
    \r\n@if (isAlphaVisible()) {\r\n
    \r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
    \r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] ++ args: [{ selector: `hsla-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ColorPickerInputDirective], template: "
    \r\n \r\n @if (labelVisible()) {\r\n H\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n S\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n L\r\n }\r\n
    \r\n@if (isAlphaVisible()) {\r\n
    \r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
    \r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] + }] }); + //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaHNsYS1pbnB1dC5jb21wb25lbnQuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi8uLi8uLi8uLi8uLi8uLi9wcm9qZWN0cy9pcGxhYi9uZ3gtY29sb3ItcGlja2VyL3NyYy9saWIvY29tcG9uZW50cy9wYXJ0cy9pbnB1dHMvaHNsYS1pbnB1dC9oc2xhLWlucHV0LmNvbXBvbmVudC50cyIsIi4uLy4uLy4uLy4uLy4uLy4uLy4uLy4uL3Byb2plY3RzL2lwbGFiL25neC1jb2xvci1waWNrZXIvc3JjL2xpYi9jb21wb25lbnRzL3BhcnRzL2lucHV0cy9oc2xhLWlucHV0L2hzbGEtaW5wdXQuY29tcG9uZW50Lmh0bWwiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLFNBQVMsRUFBRSx1QkFBdUIsRUFBRSxnQkFBZ0IsRUFBZSxLQUFLLEVBQUUsS0FBSyxFQUFlLE1BQU0sZUFBZSxDQUFDO0FBQzdILE9BQU8sRUFBRSxLQUFLLEVBQUUsTUFBTSxtQ0FBbUMsQ0FBQztBQUMxRCxPQUFPLEVBQUUseUJBQXlCLEVBQUUsTUFBTSxxREFBcUQsQ0FBQzs7QUFlaEcsTUFBTSxPQUFPLGFBQWE7SUFaMUI7UUFjVyxVQUFLLEdBQXVCLEtBQUssQ0FBQyxRQUFRLEVBQVMsQ0FBQztRQUVwRCxpQkFBWSxHQUF5QixLQUFLLENBQW1CLEtBQUssRUFBRSxFQUFFLEtBQUssRUFBRSxPQUFPLEVBQUUsU0FBUyxFQUFFLGdCQUFnQixFQUFFLENBQUMsQ0FBQztRQUVySCxtQkFBYyxHQUF5QixLQUFLLENBQW1CLElBQUksRUFBRSxFQUFFLEtBQUssRUFBRSxPQUFPLEVBQUUsU0FBUyxFQUFFLGdCQUFnQixFQUFFLENBQUMsQ0FBQztLQWdCaEk7SUFkRyxJQUFXLEtBQUs7UUFDWixPQUFPLElBQUksQ0FBQyxLQUFLLEVBQUUsRUFBRSxPQUFPLEVBQUUsQ0FBQztJQUNuQyxDQUFDO0lBRU0sYUFBYSxDQUFDLFFBQWdCLEVBQUUsS0FBNEI7UUFDL0QsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLEtBQUssQ0FBQztRQUN6QixNQUFNLEdBQUcsR0FBRyxLQUFLLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FBQyxRQUFRLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUM7UUFDakQsTUFBTSxVQUFVLEdBQUcsS0FBSyxLQUFLLEdBQUcsQ0FBQyxDQUFDLENBQUMsUUFBUSxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsVUFBVSxDQUFDO1FBQy9ELE1BQU0sU0FBUyxHQUFHLEtBQUssS0FBSyxHQUFHLENBQUMsQ0FBQyxDQUFDLFFBQVEsQ0FBQyxDQUFDLENBQUMsS0FBSyxDQUFDLFNBQVMsQ0FBQztRQUM3RCxNQUFNLEtBQUssR0FBRyxLQUFLLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FBQyxRQUFRLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxLQUFLLENBQUM7UUFFckQsTUFBTSxRQUFRLEdBQUcsSUFBSSxLQUFLLEVBQUUsQ0FBQyxPQUFPLENBQUMsR0FBRyxFQUFFLFVBQVUsRUFBRSxTQUFTLEVBQUUsS0FBSyxDQUFDLENBQUM7UUFDeEUsSUFBSSxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUMsUUFBUSxDQUFDLENBQUM7SUFDN0IsQ0FBQzs4R0FyQlEsYUFBYTtrR0FBYixhQUFhLHNnQkNqQjFCLGlsQ0F5QkMseWVEVmEseUJBQXlCOzsyRkFFMUIsYUFBYTtrQkFaekIsU0FBUzsrQkFDSSxzQkFBc0IsbUJBT2YsdUJBQXVCLENBQUMsTUFBTSxjQUNuQyxJQUFJLFdBQ1AsQ0FBQyx5QkFBeUIsQ0FBQyIsInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IENvbXBvbmVudCwgQ2hhbmdlRGV0ZWN0aW9uU3RyYXRlZ3ksIGJvb2xlYW5BdHRyaWJ1dGUsIElucHV0U2lnbmFsLCBpbnB1dCwgbW9kZWwsIE1vZGVsU2lnbmFsIH0gZnJvbSAnQGFuZ3VsYXIvY29yZSc7XHJcbmltcG9ydCB7IENvbG9yIH0gZnJvbSAnLi8uLi8uLi8uLi8uLi9oZWxwZXJzL2NvbG9yLmNsYXNzJztcclxuaW1wb3J0IHsgQ29sb3JQaWNrZXJJbnB1dERpcmVjdGl2ZSB9IGZyb20gJy4uLy4uLy4uLy4uL2RpcmVjdGl2ZXMvY29sb3ItcGlja2VyLWlucHV0LmRpcmVjdGl2ZSc7XHJcblxyXG5cclxuQENvbXBvbmVudCh7XHJcbiAgICBzZWxlY3RvcjogYGhzbGEtaW5wdXQtY29tcG9uZW50YCxcclxuICAgIHRlbXBsYXRlVXJsOiBgLi9oc2xhLWlucHV0LmNvbXBvbmVudC5odG1sYCxcclxuICAgIHN0eWxlVXJsczogW1xyXG4gICAgICAgIGAuLy4uLy4uL2Jhc2Uuc3R5bGUuc2Nzc2AsXHJcbiAgICAgICAgYC4vLi4vaW5wdXQuY29tcG9uZW50LnNjc3NgLFxyXG4gICAgICAgIGAuL2hzbGEtaW5wdXQuY29tcG9uZW50LnNjc3NgXHJcbiAgICBdLFxyXG4gICAgY2hhbmdlRGV0ZWN0aW9uOiBDaGFuZ2VEZXRlY3Rpb25TdHJhdGVneS5PblB1c2gsXHJcbiAgICBzdGFuZGFsb25lOiB0cnVlLFxyXG4gICAgaW1wb3J0czogW0NvbG9yUGlja2VySW5wdXREaXJlY3RpdmVdXHJcbn0pXHJcbmV4cG9ydCBjbGFzcyBIc2xhQ29tcG9uZW50IHtcclxuXHJcbiAgICBwdWJsaWMgY29sb3I6IE1vZGVsU2lnbmFsPENvbG9yPiA9IG1vZGVsLnJlcXVpcmVkPENvbG9yPigpO1xyXG5cclxuICAgIHB1YmxpYyBsYWJlbFZpc2libGU6IElucHV0U2lnbmFsPGJvb2xlYW4+ID0gaW5wdXQ8Ym9vbGVhbiwgYm9vbGVhbj4oZmFsc2UsIHsgYWxpYXM6ICdsYWJlbCcsIHRyYW5zZm9ybTogYm9vbGVhbkF0dHJpYnV0ZSB9KTtcclxuXHJcbiAgICBwdWJsaWMgaXNBbHBoYVZpc2libGU6IElucHV0U2lnbmFsPGJvb2xlYW4+ID0gaW5wdXQ8Ym9vbGVhbiwgYm9vbGVhbj4odHJ1ZSwgeyBhbGlhczogJ2FscGhhJywgdHJhbnNmb3JtOiBib29sZWFuQXR0cmlidXRlIH0pO1xyXG5cclxuICAgIHB1YmxpYyBnZXQgdmFsdWUoKSB7XHJcbiAgICAgICAgcmV0dXJuIHRoaXMuY29sb3IoKT8uZ2V0SHNsYSgpO1xyXG4gICAgfVxyXG5cclxuICAgIHB1YmxpYyBvbklucHV0Q2hhbmdlKG5ld1ZhbHVlOiBudW1iZXIsIGNvbG9yOiAnSCcgfCAnUycgfCAnTCcgfCAnQScpIHtcclxuICAgICAgICBjb25zdCB2YWx1ZSA9IHRoaXMudmFsdWU7XHJcbiAgICAgICAgY29uc3QgaHVlID0gY29sb3IgPT09ICdIJyA/IG5ld1ZhbHVlIDogdmFsdWUuaHVlO1xyXG4gICAgICAgIGNvbnN0IHNhdHVyYXRpb24gPSBjb2xvciA9PT0gJ1MnID8gbmV3VmFsdWUgOiB2YWx1ZS5zYXR1cmF0aW9uO1xyXG4gICAgICAgIGNvbnN0IGxpZ2h0bmVzcyA9IGNvbG9yID09PSAnTCcgPyBuZXdWYWx1ZSA6IHZhbHVlLmxpZ2h0bmVzcztcclxuICAgICAgICBjb25zdCBhbHBoYSA9IGNvbG9yID09PSAnQScgPyBuZXdWYWx1ZSA6IHZhbHVlLmFscGhhO1xyXG5cclxuICAgICAgICBjb25zdCBuZXdDb2xvciA9IG5ldyBDb2xvcigpLnNldEhzbGEoaHVlLCBzYXR1cmF0aW9uLCBsaWdodG5lc3MsIGFscGhhKTtcclxuICAgICAgICB0aGlzLmNvbG9yLnNldChuZXdDb2xvcik7XHJcbiAgICB9XHJcbn1cclxuIiwiPGRpdiBjbGFzcz1cImNvbHVtblwiPlxyXG4gICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgcGF0dGVybj1cIlswLTldKlwiIG1pbj1cIjBcIiBtYXg9XCIzNjBcIiBbdmFsdWVdPVwidmFsdWU/LmdldEh1ZSgpLnRvU3RyaW5nKClcIiAoaW5wdXRDaGFuZ2UpPVwib25JbnB1dENoYW5nZSgkZXZlbnQsICdIJylcIiAvPlxyXG4gICAgQGlmIChsYWJlbFZpc2libGUoKSkge1xyXG4gICAgICAgIDxzcGFuPkg8L3NwYW4+XHJcbiAgICB9XHJcbjwvZGl2PlxyXG48ZGl2IGNsYXNzPVwiY29sdW1uXCI+XHJcbiAgICA8aW5wdXQgdHlwZT1cInRleHRcIiBwYXR0ZXJuPVwiWzAtOV0qXCIgbWluPVwiMFwiIG1heD1cIjEwMFwiIFt2YWx1ZV09XCJ2YWx1ZT8uZ2V0U2F0dXJhdGlvbigpICsgJyUnXCIgKGlucHV0Q2hhbmdlKT1cIm9uSW5wdXRDaGFuZ2UoJGV2ZW50LCAnUycpXCIgLz5cclxuICAgIEBpZiAobGFiZWxWaXNpYmxlKCkpIHtcclxuICAgICAgICA8c3Bhbj5TPC9zcGFuPlxyXG4gICAgfVxyXG48L2Rpdj5cclxuPGRpdiBjbGFzcz1cImNvbHVtblwiPlxyXG4gICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgcGF0dGVybj1cIlswLTldKlwiIG1pbj1cIjBcIiBtYXg9XCIxMDBcIiBbdmFsdWVdPVwidmFsdWU/LmdldExpZ2h0bmVzcygpICsgJyUnXCIgKGlucHV0Q2hhbmdlKT1cIm9uSW5wdXRDaGFuZ2UoJGV2ZW50LCAnTCcpXCIgLz5cclxuICAgIEBpZiAobGFiZWxWaXNpYmxlKCkpIHtcclxuICAgICAgICA8c3Bhbj5MPC9zcGFuPlxyXG4gICAgfVxyXG48L2Rpdj5cclxuQGlmIChpc0FscGhhVmlzaWJsZSgpKSB7XHJcbiAgICA8ZGl2IGNsYXNzPVwiY29sdW1uXCI+XHJcbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgcGF0dGVybj1cIlswLTldKyhbXFwuLF1bMC05XXsxLDJ9KT9cIiBtaW49XCIwXCIgbWF4PVwiMVwiIFt2YWx1ZV09XCJ2YWx1ZT8uZ2V0QWxwaGEoKS50b1N0cmluZygpXCIgKGlucHV0Q2hhbmdlKT1cIm9uSW5wdXRDaGFuZ2UoJGV2ZW50LCAnQScpXCIgLz5cclxuICAgICAgICBAaWYgKGxhYmVsVmlzaWJsZSgpKSB7XHJcbiAgICAgICAgICAgIDxzcGFuPkE8L3NwYW4+XHJcbiAgICAgICAgfVxyXG4gICAgPC9kaXY+XHJcbn0iXX0= +diff --git a/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/rgba-input/rgba-input.component.mjs b/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/rgba-input/rgba-input.component.mjs +index 6d976e8..8989aad 100644 +--- a/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/rgba-input/rgba-input.component.mjs ++++ b/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/rgba-input/rgba-input.component.mjs +@@ -21,10 +21,10 @@ export class RgbaComponent { + this.color.set(newColor); + } + static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: RgbaComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } +- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: RgbaComponent, isStandalone: true, selector: "rgba-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
    \r\n \r\n @if (labelVisible()) {\r\n R\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n G\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n B\r\n }\r\n
    \r\n@if (isAlphaVisible()) {\r\n
    \r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
    \r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } ++ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: RgbaComponent, isStandalone: true, selector: "rgba-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
    \r\n \r\n @if (labelVisible()) {\r\n R\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n G\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n B\r\n }\r\n
    \r\n@if (isAlphaVisible()) {\r\n
    \r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
    \r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } + } + i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: RgbaComponent, decorators: [{ + type: Component, +- args: [{ selector: `rgba-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ColorPickerInputDirective], template: "
    \r\n \r\n @if (labelVisible()) {\r\n R\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n G\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n B\r\n }\r\n
    \r\n@if (isAlphaVisible()) {\r\n
    \r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
    \r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] ++ args: [{ selector: `rgba-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ColorPickerInputDirective], template: "
    \r\n \r\n @if (labelVisible()) {\r\n R\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n G\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n B\r\n }\r\n
    \r\n@if (isAlphaVisible()) {\r\n
    \r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
    \r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] + }] }); + //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmdiYS1pbnB1dC5jb21wb25lbnQuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi8uLi8uLi8uLi8uLi8uLi9wcm9qZWN0cy9pcGxhYi9uZ3gtY29sb3ItcGlja2VyL3NyYy9saWIvY29tcG9uZW50cy9wYXJ0cy9pbnB1dHMvcmdiYS1pbnB1dC9yZ2JhLWlucHV0LmNvbXBvbmVudC50cyIsIi4uLy4uLy4uLy4uLy4uLy4uLy4uLy4uL3Byb2plY3RzL2lwbGFiL25neC1jb2xvci1waWNrZXIvc3JjL2xpYi9jb21wb25lbnRzL3BhcnRzL2lucHV0cy9yZ2JhLWlucHV0L3JnYmEtaW5wdXQuY29tcG9uZW50Lmh0bWwiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLFNBQVMsRUFBRSx1QkFBdUIsRUFBRSxnQkFBZ0IsRUFBZSxLQUFLLEVBQUUsS0FBSyxFQUFlLE1BQU0sZUFBZSxDQUFDO0FBQzdILE9BQU8sRUFBRSxLQUFLLEVBQUUsTUFBTSxtQ0FBbUMsQ0FBQztBQUMxRCxPQUFPLEVBQUUseUJBQXlCLEVBQUUsTUFBTSxxREFBcUQsQ0FBQzs7QUFlaEcsTUFBTSxPQUFPLGFBQWE7SUFaMUI7UUFjVyxVQUFLLEdBQXVCLEtBQUssQ0FBQyxRQUFRLEVBQVMsQ0FBQztRQUVwRCxpQkFBWSxHQUF5QixLQUFLLENBQW1CLEtBQUssRUFBRSxFQUFFLEtBQUssRUFBRSxPQUFPLEVBQUUsU0FBUyxFQUFFLGdCQUFnQixFQUFFLENBQUMsQ0FBQztRQUVySCxtQkFBYyxHQUF5QixLQUFLLENBQW1CLElBQUksRUFBRSxFQUFFLEtBQUssRUFBRSxPQUFPLEVBQUUsU0FBUyxFQUFFLGdCQUFnQixFQUFFLENBQUMsQ0FBQztLQWdCaEk7SUFkRyxJQUFXLEtBQUs7UUFDWixPQUFPLElBQUksQ0FBQyxLQUFLLEVBQUUsRUFBRSxPQUFPLEVBQUUsQ0FBQztJQUNuQyxDQUFDO0lBRU0sYUFBYSxDQUFDLFFBQWdCLEVBQUUsS0FBNEI7UUFDL0QsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLEtBQUssQ0FBQztRQUN6QixNQUFNLEdBQUcsR0FBRyxLQUFLLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FBQyxRQUFRLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUM7UUFDakQsTUFBTSxLQUFLLEdBQUcsS0FBSyxLQUFLLEdBQUcsQ0FBQyxDQUFDLENBQUMsUUFBUSxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsS0FBSyxDQUFDO1FBQ3JELE1BQU0sSUFBSSxHQUFHLEtBQUssS0FBSyxHQUFHLENBQUMsQ0FBQyxDQUFDLFFBQVEsQ0FBQyxDQUFDLENBQUMsS0FBSyxDQUFDLElBQUksQ0FBQztRQUNuRCxNQUFNLEtBQUssR0FBRyxLQUFLLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FBQyxRQUFRLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxLQUFLLENBQUM7UUFFckQsTUFBTSxRQUFRLEdBQUcsSUFBSSxLQUFLLEVBQUUsQ0FBQyxPQUFPLENBQUMsR0FBRyxFQUFFLEtBQUssRUFBRSxJQUFJLEVBQUUsS0FBSyxDQUFDLENBQUM7UUFDOUQsSUFBSSxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUMsUUFBUSxDQUFDLENBQUM7SUFDN0IsQ0FBQzs4R0FyQlEsYUFBYTtrR0FBYixhQUFhLHNnQkNqQjFCLGlsQ0F5QkMseWVEVmEseUJBQXlCOzsyRkFFMUIsYUFBYTtrQkFaekIsU0FBUzsrQkFDSSxzQkFBc0IsbUJBT2YsdUJBQXVCLENBQUMsTUFBTSxjQUNuQyxJQUFJLFdBQ1AsQ0FBQyx5QkFBeUIsQ0FBQyIsInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IENvbXBvbmVudCwgQ2hhbmdlRGV0ZWN0aW9uU3RyYXRlZ3ksIGJvb2xlYW5BdHRyaWJ1dGUsIElucHV0U2lnbmFsLCBpbnB1dCwgbW9kZWwsIE1vZGVsU2lnbmFsIH0gZnJvbSAnQGFuZ3VsYXIvY29yZSc7XHJcbmltcG9ydCB7IENvbG9yIH0gZnJvbSAnLi8uLi8uLi8uLi8uLi9oZWxwZXJzL2NvbG9yLmNsYXNzJztcclxuaW1wb3J0IHsgQ29sb3JQaWNrZXJJbnB1dERpcmVjdGl2ZSB9IGZyb20gJy4uLy4uLy4uLy4uL2RpcmVjdGl2ZXMvY29sb3ItcGlja2VyLWlucHV0LmRpcmVjdGl2ZSc7XHJcblxyXG5cclxuQENvbXBvbmVudCh7XHJcbiAgICBzZWxlY3RvcjogYHJnYmEtaW5wdXQtY29tcG9uZW50YCxcclxuICAgIHRlbXBsYXRlVXJsOiBgLi9yZ2JhLWlucHV0LmNvbXBvbmVudC5odG1sYCxcclxuICAgIHN0eWxlVXJsczogW1xyXG4gICAgICAgIGAuLy4uLy4uL2Jhc2Uuc3R5bGUuc2Nzc2AsXHJcbiAgICAgICAgYC4vLi4vaW5wdXQuY29tcG9uZW50LnNjc3NgLFxyXG4gICAgICAgIGAuL3JnYmEtaW5wdXQuY29tcG9uZW50LnNjc3NgXHJcbiAgICBdLFxyXG4gICAgY2hhbmdlRGV0ZWN0aW9uOiBDaGFuZ2VEZXRlY3Rpb25TdHJhdGVneS5PblB1c2gsXHJcbiAgICBzdGFuZGFsb25lOiB0cnVlLFxyXG4gICAgaW1wb3J0czogW0NvbG9yUGlja2VySW5wdXREaXJlY3RpdmVdXHJcbn0pXHJcbmV4cG9ydCBjbGFzcyBSZ2JhQ29tcG9uZW50IHtcclxuXHJcbiAgICBwdWJsaWMgY29sb3I6IE1vZGVsU2lnbmFsPENvbG9yPiA9IG1vZGVsLnJlcXVpcmVkPENvbG9yPigpO1xyXG5cclxuICAgIHB1YmxpYyBsYWJlbFZpc2libGU6IElucHV0U2lnbmFsPGJvb2xlYW4+ID0gaW5wdXQ8Ym9vbGVhbiwgYm9vbGVhbj4oZmFsc2UsIHsgYWxpYXM6ICdsYWJlbCcsIHRyYW5zZm9ybTogYm9vbGVhbkF0dHJpYnV0ZSB9KTtcclxuXHJcbiAgICBwdWJsaWMgaXNBbHBoYVZpc2libGU6IElucHV0U2lnbmFsPGJvb2xlYW4+ID0gaW5wdXQ8Ym9vbGVhbiwgYm9vbGVhbj4odHJ1ZSwgeyBhbGlhczogJ2FscGhhJywgdHJhbnNmb3JtOiBib29sZWFuQXR0cmlidXRlIH0pO1xyXG5cclxuICAgIHB1YmxpYyBnZXQgdmFsdWUoKSB7XHJcbiAgICAgICAgcmV0dXJuIHRoaXMuY29sb3IoKT8uZ2V0UmdiYSgpO1xyXG4gICAgfVxyXG5cclxuICAgIHB1YmxpYyBvbklucHV0Q2hhbmdlKG5ld1ZhbHVlOiBudW1iZXIsIGNvbG9yOiAnUicgfCAnRycgfCAnQicgfCAnQScpIHtcclxuICAgICAgICBjb25zdCB2YWx1ZSA9IHRoaXMudmFsdWU7XHJcbiAgICAgICAgY29uc3QgcmVkID0gY29sb3IgPT09ICdSJyA/IG5ld1ZhbHVlIDogdmFsdWUucmVkO1xyXG4gICAgICAgIGNvbnN0IGdyZWVuID0gY29sb3IgPT09ICdHJyA/IG5ld1ZhbHVlIDogdmFsdWUuZ3JlZW47XHJcbiAgICAgICAgY29uc3QgYmx1ZSA9IGNvbG9yID09PSAnQicgPyBuZXdWYWx1ZSA6IHZhbHVlLmJsdWU7XHJcbiAgICAgICAgY29uc3QgYWxwaGEgPSBjb2xvciA9PT0gJ0EnID8gbmV3VmFsdWUgOiB2YWx1ZS5hbHBoYTtcclxuXHJcbiAgICAgICAgY29uc3QgbmV3Q29sb3IgPSBuZXcgQ29sb3IoKS5zZXRSZ2JhKHJlZCwgZ3JlZW4sIGJsdWUsIGFscGhhKTtcclxuICAgICAgICB0aGlzLmNvbG9yLnNldChuZXdDb2xvcik7XHJcbiAgICB9XHJcbn1cclxuIiwiPGRpdiBjbGFzcz1cImNvbHVtblwiPlxyXG4gICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgcGF0dGVybj1cIlswLTldKlwiIG1pbj1cIjBcIiBtYXg9XCIyNTVcIiBbdmFsdWVdPVwidmFsdWU/LmdldFJlZCgpLnRvU3RyaW5nKClcIiAoaW5wdXRDaGFuZ2UpPVwib25JbnB1dENoYW5nZSgkZXZlbnQsICdSJylcIiAvPlxyXG4gICAgQGlmIChsYWJlbFZpc2libGUoKSkge1xyXG4gICAgICAgIDxzcGFuPlI8L3NwYW4+XHJcbiAgICB9XHJcbjwvZGl2PlxyXG48ZGl2IGNsYXNzPVwiY29sdW1uXCI+XHJcbiAgICA8aW5wdXQgdHlwZT1cInRleHRcIiBwYXR0ZXJuPVwiWzAtOV0qXCIgbWluPVwiMFwiIG1heD1cIjI1NVwiIFt2YWx1ZV09XCJ2YWx1ZT8uZ2V0R3JlZW4oKS50b1N0cmluZygpXCIgKGlucHV0Q2hhbmdlKT1cIm9uSW5wdXRDaGFuZ2UoJGV2ZW50LCAnRycpXCIgLz5cclxuICAgIEBpZiAobGFiZWxWaXNpYmxlKCkpIHtcclxuICAgICAgICA8c3Bhbj5HPC9zcGFuPlxyXG4gICAgfVxyXG48L2Rpdj5cclxuPGRpdiBjbGFzcz1cImNvbHVtblwiPlxyXG4gICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgcGF0dGVybj1cIlswLTldKlwiIG1pbj1cIjBcIiBtYXg9XCIyNTVcIiBbdmFsdWVdPVwidmFsdWU/LmdldEJsdWUoKS50b1N0cmluZygpXCIgKGlucHV0Q2hhbmdlKT1cIm9uSW5wdXRDaGFuZ2UoJGV2ZW50LCAnQicpXCIgLz5cclxuICAgIEBpZiAobGFiZWxWaXNpYmxlKCkpIHtcclxuICAgICAgICA8c3Bhbj5CPC9zcGFuPlxyXG4gICAgfVxyXG48L2Rpdj5cclxuQGlmIChpc0FscGhhVmlzaWJsZSgpKSB7XHJcbiAgICA8ZGl2IGNsYXNzPVwiY29sdW1uXCI+XHJcbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgcGF0dGVybj1cIlswLTldKyhbXFwuLF1bMC05XXsxLDJ9KT9cIiBtaW49XCIwXCIgbWF4PVwiMVwiIFt2YWx1ZV09XCJ2YWx1ZT8uZ2V0QWxwaGEoKS50b1N0cmluZygpXCIgKGlucHV0Q2hhbmdlKT1cIm9uSW5wdXRDaGFuZ2UoJGV2ZW50LCAnQScpXCIgLz5cclxuICAgICAgICBAaWYgKGxhYmVsVmlzaWJsZSgpKSB7XHJcbiAgICAgICAgICAgIDxzcGFuPkE8L3NwYW4+XHJcbiAgICAgICAgfVxyXG4gICAgPC9kaXY+XHJcbn0iXX0= +diff --git a/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs b/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs +index a3b270c..40bea7f 100644 +--- a/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs ++++ b/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs +@@ -1123,11 +1123,11 @@ class RgbaComponent { + this.color.set(newColor); + } + static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: RgbaComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } +- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: RgbaComponent, isStandalone: true, selector: "rgba-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
    \r\n \r\n @if (labelVisible()) {\r\n R\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n G\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n B\r\n }\r\n
    \r\n@if (isAlphaVisible()) {\r\n
    \r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
    \r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } ++ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: RgbaComponent, isStandalone: true, selector: "rgba-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
    \r\n \r\n @if (labelVisible()) {\r\n R\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n G\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n B\r\n }\r\n
    \r\n@if (isAlphaVisible()) {\r\n
    \r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
    \r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } + } + i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: RgbaComponent, decorators: [{ + type: Component, +- args: [{ selector: `rgba-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ColorPickerInputDirective], template: "
    \r\n \r\n @if (labelVisible()) {\r\n R\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n G\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n B\r\n }\r\n
    \r\n@if (isAlphaVisible()) {\r\n
    \r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
    \r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] ++ args: [{ selector: `rgba-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ColorPickerInputDirective], template: "
    \r\n \r\n @if (labelVisible()) {\r\n R\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n G\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n B\r\n }\r\n
    \r\n@if (isAlphaVisible()) {\r\n
    \r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
    \r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] + }] }); + + class HslaComponent { +@@ -1149,11 +1149,11 @@ class HslaComponent { + this.color.set(newColor); + } + static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: HslaComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } +- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: HslaComponent, isStandalone: true, selector: "hsla-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
    \r\n \r\n @if (labelVisible()) {\r\n H\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n S\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n L\r\n }\r\n
    \r\n@if (isAlphaVisible()) {\r\n
    \r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
    \r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } ++ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: HslaComponent, isStandalone: true, selector: "hsla-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
    \r\n \r\n @if (labelVisible()) {\r\n H\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n S\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n L\r\n }\r\n
    \r\n@if (isAlphaVisible()) {\r\n
    \r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
    \r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } + } + i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: HslaComponent, decorators: [{ + type: Component, +- args: [{ selector: `hsla-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ColorPickerInputDirective], template: "
    \r\n \r\n @if (labelVisible()) {\r\n H\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n S\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n L\r\n }\r\n
    \r\n@if (isAlphaVisible()) {\r\n
    \r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
    \r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] ++ args: [{ selector: `hsla-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ColorPickerInputDirective], template: "
    \r\n \r\n @if (labelVisible()) {\r\n H\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n S\r\n }\r\n
    \r\n
    \r\n \r\n @if (labelVisible()) {\r\n L\r\n }\r\n
    \r\n@if (isAlphaVisible()) {\r\n
    \r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
    \r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] + }] }); + + class HexComponent { +@@ -1184,11 +1184,11 @@ class HexComponent { + } + } + static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: HexComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } +- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: HexComponent, isStandalone: true, selector: "hex-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, prefixValue: { classPropertyName: "prefixValue", publicName: "prefix", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
    \r\n \r\n @if (labelVisible()) {\r\n HEX\r\n }\r\n
    ", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } ++ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: HexComponent, isStandalone: true, selector: "hex-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, prefixValue: { classPropertyName: "prefixValue", publicName: "prefix", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
    \r\n \r\n @if (labelVisible()) {\r\n HEX\r\n }\r\n
    ", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } + } + i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: HexComponent, decorators: [{ + type: Component, +- args: [{ selector: `hex-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, template: "
    \r\n \r\n @if (labelVisible()) {\r\n HEX\r\n }\r\n
    ", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] ++ args: [{ selector: `hex-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, template: "
    \r\n \r\n @if (labelVisible()) {\r\n HEX\r\n }\r\n
    ", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] + }] }); + + const OpacityAnimation = trigger('opacityAnimation', [ diff --git a/ui-ngx/src/app/core/auth/auth.models.ts b/ui-ngx/src/app/core/auth/auth.models.ts index d6612f2427..6e4d324b5b 100644 --- a/ui-ngx/src/app/core/auth/auth.models.ts +++ b/ui-ngx/src/app/core/auth/auth.models.ts @@ -31,6 +31,7 @@ export interface SysParamsState { maxDebugModeDurationMinutes: number; maxDataPointsPerRollingArg: number; maxArgumentsPerCF: number; + minAllowedDeduplicationIntervalInSecForCF: number; minAllowedScheduledUpdateIntervalInSecForCF: number; maxRelationLevelPerCfArgument: number; ruleChainDebugPerTenantLimitsConfiguration?: string; diff --git a/ui-ngx/src/app/core/auth/auth.reducer.ts b/ui-ngx/src/app/core/auth/auth.reducer.ts index 8cfbc04197..777cf5308e 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -33,6 +33,7 @@ const emptyUserAuthState: AuthPayload = { mobileQrEnabled: false, maxResourceSize: 0, maxArgumentsPerCF: 0, + minAllowedDeduplicationIntervalInSecForCF: 0, minAllowedScheduledUpdateIntervalInSecForCF: 0, maxRelationLevelPerCfArgument: 0, maxDataPointsPerRollingArg: 0, diff --git a/ui-ngx/src/app/core/auth/auth.service.ts b/ui-ngx/src/app/core/auth/auth.service.ts index f8ffd5e8b6..3b6a15688c 100644 --- a/ui-ngx/src/app/core/auth/auth.service.ts +++ b/ui-ngx/src/app/core/auth/auth.service.ts @@ -68,6 +68,7 @@ export class AuthService { redirectUrl: string; oauth2Clients: Array = null; twoFactorAuthProviders: Array = null; + forceTwoFactorAuthProviders: Array = null; private refreshTokenSubject: ReplaySubject = null; private jwtHelper = new JwtHelperService(); @@ -117,6 +118,9 @@ export class AuthService { if (loginResponse.scope === Authority.PRE_VERIFICATION_TOKEN) { this.router.navigateByUrl(`login/mfa`); } + if (loginResponse.scope === Authority.MFA_CONFIGURATION_TOKEN) { + this.router.navigateByUrl(`login/force-mfa`); + } } )); } @@ -239,6 +243,15 @@ export class AuthService { ); } + public getAvailableTwoFaProviders(): Observable> { + return this.http.get>(`/api/2fa/providers`, defaultHttpOptions()).pipe( + catchError(() => of([])), + tap((providers) => { + this.forceTwoFactorAuthProviders = providers; + }) + ); + } + public forceDefaultPlace(authState?: AuthState, path?: string, params?: any): boolean { if (authState && authState.authUser) { if (authState.authUser.authority === Authority.TENANT_ADMIN || authState.authUser.authority === Authority.CUSTOMER_USER) { @@ -266,6 +279,8 @@ export class AuthService { if (isAuthenticated) { if (authState.authUser.authority === Authority.PRE_VERIFICATION_TOKEN) { result = this.router.parseUrl('login/mfa'); + } else if (authState.authUser.authority === Authority.MFA_CONFIGURATION_TOKEN) { + result = this.router.parseUrl('login/force-mfa'); } else if (!path || path === 'login' || this.forceDefaultPlace(authState, path, params)) { if (this.redirectUrl) { const redirectUrl = this.redirectUrl; @@ -399,7 +414,7 @@ export class AuthService { loadUserSubject.error(err); } ); - } else if (authPayload.authUser?.authority === Authority.PRE_VERIFICATION_TOKEN) { + } else if (authPayload.authUser?.authority === Authority.PRE_VERIFICATION_TOKEN || authPayload.authUser?.authority === Authority.MFA_CONFIGURATION_TOKEN) { loadUserSubject.next(authPayload); loadUserSubject.complete(); } else if (authPayload.authUser?.userId) { diff --git a/ui-ngx/src/app/core/guards/auth.guard.ts b/ui-ngx/src/app/core/guards/auth.guard.ts index f6d8753882..5090af97ec 100644 --- a/ui-ngx/src/app/core/guards/auth.guard.ts +++ b/ui-ngx/src/app/core/guards/auth.guard.ts @@ -104,6 +104,16 @@ export class AuthGuard { } this.authService.logout(); return of(this.authService.defaultUrl(false)); + } else if (path === 'login.force-mfa') { + if (authState.authUser?.authority === Authority.MFA_CONFIGURATION_TOKEN) { + return this.authService.getAvailableTwoFaProviders().pipe( + map(() => { + return true; + }) + ); + } + this.authService.logout(); + return of(this.authService.defaultUrl(false)); } else { return of(true); } @@ -121,7 +131,7 @@ export class AuthGuard { } } if (this.mobileService.isMobileApp() && !path.startsWith('dashboard.')) { - this.mobileService.handleMobileNavigation(path, params); + this.mobileService.handleMobileNavigation(path, params, lastChild.queryParams); return of(false); } if (authState.authUser.authority === Authority.PRE_VERIFICATION_TOKEN) { diff --git a/ui-ngx/src/app/core/http/admin.service.ts b/ui-ngx/src/app/core/http/admin.service.ts index 55eaa16b90..36bb931f93 100644 --- a/ui-ngx/src/app/core/http/admin.service.ts +++ b/ui-ngx/src/app/core/http/admin.service.ts @@ -21,9 +21,9 @@ import { HttpClient } from '@angular/common/http'; import { AdminSettings, AutoCommitSettings, - MailConfigTemplate, FeaturesInfo, JwtSettings, + MailConfigTemplate, MailServerSettings, RepositorySettings, RepositorySettingsInfo, diff --git a/ui-ngx/src/app/core/http/asset.service.ts b/ui-ngx/src/app/core/http/asset.service.ts index a24be23593..0efeec3eef 100644 --- a/ui-ngx/src/app/core/http/asset.service.ts +++ b/ui-ngx/src/app/core/http/asset.service.ts @@ -15,7 +15,7 @@ /// import { Injectable } from '@angular/core'; -import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { createDefaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; @@ -23,6 +23,7 @@ import { PageData } from '@shared/models/page/page-data'; import { EntitySubtype } from '@shared/models/entity-type.models'; import { Asset, AssetInfo, AssetSearchQuery } from '@shared/models/asset.models'; import { BulkImportRequest, BulkImportResult } from '@shared/import-export/import-export.models'; +import { SaveEntityParams } from '@shared/models/entity.models'; @Injectable({ providedIn: 'root' @@ -69,8 +70,10 @@ export class AssetService { return this.http.get(`/api/asset/info/${assetId}`, defaultHttpOptionsFromConfig(config)); } - public saveAsset(asset: Asset, config?: RequestConfig): Observable { - return this.http.post('/api/asset', asset, defaultHttpOptionsFromConfig(config)); + public saveAsset(asset: Asset, config?: RequestConfig): Observable; + public saveAsset(asset: Asset, saveParams: SaveEntityParams, config?: RequestConfig): Observable; + public saveAsset(asset: Asset, saveParamsOrConfig?: SaveEntityParams | RequestConfig, config?: RequestConfig): Observable { + return this.http.post('/api/asset', asset, createDefaultHttpOptions(saveParamsOrConfig, config)); } public deleteAsset(assetId: string, config?: RequestConfig) { diff --git a/ui-ngx/src/app/core/http/attribute.service.ts b/ui-ngx/src/app/core/http/attribute.service.ts index 8687d3ec0a..fc87702628 100644 --- a/ui-ngx/src/app/core/http/attribute.service.ts +++ b/ui-ngx/src/app/core/http/attribute.service.ts @@ -15,7 +15,7 @@ /// import { Injectable } from '@angular/core'; -import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { defaultHttpOptionsFromConfig, defaultHttpOptionsFromParams, RequestConfig } from './http-utils'; import { forkJoin, Observable, of } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { EntityId } from '@shared/models/id/entity-id'; @@ -35,47 +35,37 @@ export class AttributeService { public getEntityAttributes(entityId: EntityId, attributeScope?: AttributeScope, keys?: Array, config?: RequestConfig): Observable> { let url = `/api/plugins/telemetry/${entityId.entityType}/${entityId.id}/values/attributes`; - + let queryParams: object = null; if (attributeScope) { url += `/${attributeScope}`; } if (keys && keys.length) { - url += `?keys=${keys.join(',')}`; + queryParams = {key: keys}; } - return this.http.get>(url, defaultHttpOptionsFromConfig(config)); + return this.http.get>(url, defaultHttpOptionsFromParams(queryParams, config)); } public deleteEntityAttributes(entityId: EntityId, attributeScope: AttributeScope, attributes: Array, config?: RequestConfig): Observable { - const keys = attributes.map(attribute => encodeURIComponent(attribute.key)).join(','); - return this.http.delete(`/api/plugins/telemetry/${entityId.entityType}/${entityId.id}/${attributeScope}` + - `?keys=${keys}`, - defaultHttpOptionsFromConfig(config)); + return this.http.delete(`/api/plugins/telemetry/${entityId.entityType}/${entityId.id}/${attributeScope}`, + defaultHttpOptionsFromParams({key: attributes.map(attribute => attribute.key)}, config)); } public deleteEntityTimeseries(entityId: EntityId, timeseries: Array, deleteAllDataForKeys = false, startTs?: number, endTs?: number, rewriteLatestIfDeleted = false, deleteLatest = true, config?: RequestConfig): Observable { - const keys = timeseries.map(attribute => encodeURIComponent(attribute.key)).join(','); - let url = `/api/plugins/telemetry/${entityId.entityType}/${entityId.id}/timeseries/delete?keys=${keys}`; - if (isDefinedAndNotNull(deleteAllDataForKeys)) { - url += `&deleteAllDataForKeys=${deleteAllDataForKeys}`; - } - if (isDefinedAndNotNull(rewriteLatestIfDeleted)) { - url += `&rewriteLatestIfDeleted=${rewriteLatestIfDeleted}`; - } - if (isDefinedAndNotNull(deleteLatest)) { - url += `&deleteLatest=${deleteLatest}`; - } - if (isDefinedAndNotNull(startTs)) { - url += `&startTs=${startTs}`; - } - if (isDefinedAndNotNull(endTs)) { - url += `&endTs=${endTs}`; - } - return this.http.delete(url, defaultHttpOptionsFromConfig(config)); + const queryParams = { + key: timeseries.map(key => key.key), + deleteAllDataForKeys: deleteAllDataForKeys, + rewriteLatestIfDeleted: rewriteLatestIfDeleted, + deleteLatest: deleteLatest, + startTs: startTs, + endTs: endTs + }; + return this.http.delete(`/api/plugins/telemetry/${entityId.entityType}/${entityId.id}/timeseries/delete`, + defaultHttpOptionsFromParams(queryParams, config)); } public saveEntityAttributes(entityId: EntityId, attributeScope: AttributeScope, attributes: Array, @@ -138,32 +128,29 @@ export class AttributeService { limit: number = 100, agg: AggregationType = AggregationType.NONE, interval?: number, orderBy: DataSortOrder = DataSortOrder.DESC, useStrictDataTypes: boolean = false, config?: RequestConfig): Observable { - let url = `/api/plugins/telemetry/${entityId.entityType}/${entityId.id}/values/timeseries?keys=${keys.join(',')}&startTs=${startTs}&endTs=${endTs}`; - if (isDefinedAndNotNull(limit)) { - url += `&limit=${limit}`; - } - if (isDefinedAndNotNull(agg)) { - url += `&agg=${agg}`; - } - if (isDefinedAndNotNull(interval)) { - url += `&interval=${interval}`; - } - if (isDefinedAndNotNull(orderBy)) { - url += `&orderBy=${orderBy}`; - } - if (isDefinedAndNotNull(useStrictDataTypes)) { - url += `&useStrictDataTypes=${useStrictDataTypes}`; - } - - return this.http.get(url, defaultHttpOptionsFromConfig(config)); + const queryParams = { + key: keys, + startTs: startTs, + endTs: endTs, + limit: limit, + agg: agg, + interval: interval, + orderBy: orderBy, + useStrictDataTypes: useStrictDataTypes + } + return this.http.get(`/api/plugins/telemetry/${entityId.entityType}/${entityId.id}/values/timeseries`, + defaultHttpOptionsFromParams(queryParams, config)); } public getEntityTimeseriesLatest(entityId: EntityId, keys?: Array, useStrictDataTypes = false, config?: RequestConfig): Observable { - let url = `/api/plugins/telemetry/${entityId.entityType}/${entityId.id}/values/timeseries?useStrictDataTypes=${useStrictDataTypes}`; + const queryParams: Record = { + useStrictDataTypes: useStrictDataTypes + } if (isDefinedAndNotNull(keys) && keys.length) { - url += `&keys=${keys.join(',')}`; + queryParams.key = keys; } - return this.http.get(url, defaultHttpOptionsFromConfig(config)); + return this.http.get(`/api/plugins/telemetry/${entityId.entityType}/${entityId.id}/values/timeseries`, + defaultHttpOptionsFromParams(queryParams, config)); } } diff --git a/ui-ngx/src/app/core/http/calculated-fields.service.ts b/ui-ngx/src/app/core/http/calculated-fields.service.ts index 66c0cb609e..631550ae9e 100644 --- a/ui-ngx/src/app/core/http/calculated-fields.service.ts +++ b/ui-ngx/src/app/core/http/calculated-fields.service.ts @@ -15,11 +15,15 @@ /// import { Injectable } from '@angular/core'; -import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { createDefaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { PageData } from '@shared/models/page/page-data'; -import { CalculatedField, CalculatedFieldTestScriptInputParams } from '@shared/models/calculated-field.models'; +import { + CalculatedField, + CalculatedFieldTestScriptInputParams, + CalculatedFieldType +} from '@shared/models/calculated-field.models'; import { PageLink } from '@shared/models/page/page-link'; import { EntityId } from '@shared/models/id/entity-id'; import { EntityTestScriptResult } from '@shared/models/entity.models'; @@ -46,9 +50,8 @@ export class CalculatedFieldsService { return this.http.delete(`/api/calculatedField/${calculatedFieldId}`, defaultHttpOptionsFromConfig(config)); } - public getCalculatedFields({ entityType, id }: EntityId, pageLink: PageLink, config?: RequestConfig): Observable> { - return this.http.get>(`/api/${entityType}/${id}/calculatedFields${pageLink.toQuery()}`, - defaultHttpOptionsFromConfig(config)); + public getCalculatedFields({ entityType, id }: EntityId, pageLink: PageLink, type?: CalculatedFieldType, config?: RequestConfig): Observable> { + return this.http.get>(`/api/${entityType}/${id}/calculatedFields${pageLink.toQuery()}`, createDefaultHttpOptions(type ? {type} : null, config)); } public testScript(inputParams: CalculatedFieldTestScriptInputParams, config?: RequestConfig): Observable { diff --git a/ui-ngx/src/app/core/http/customer.service.ts b/ui-ngx/src/app/core/http/customer.service.ts index faf78f6f75..ec8955ca4b 100644 --- a/ui-ngx/src/app/core/http/customer.service.ts +++ b/ui-ngx/src/app/core/http/customer.service.ts @@ -15,12 +15,13 @@ /// import { Injectable } from '@angular/core'; -import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { createDefaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; import { PageData } from '@shared/models/page/page-data'; import { Customer } from '@shared/models/customer.model'; +import { SaveEntityParams } from '@shared/models/entity.models'; @Injectable({ providedIn: 'root' @@ -40,8 +41,10 @@ export class CustomerService { return this.http.get(`/api/customer/${customerId}`, defaultHttpOptionsFromConfig(config)); } - public saveCustomer(customer: Customer, config?: RequestConfig): Observable { - return this.http.post('/api/customer', customer, defaultHttpOptionsFromConfig(config)); + public saveCustomer(customer: Customer, config?: RequestConfig): Observable; + public saveCustomer(customer: Customer, saveParams: SaveEntityParams, config?: RequestConfig): Observable; + public saveCustomer(customer: Customer, saveParamsOrConfig?: SaveEntityParams | RequestConfig, config?: RequestConfig): Observable { + return this.http.post('/api/customer', customer, createDefaultHttpOptions(saveParamsOrConfig, config)); } public deleteCustomer(customerId: string, config?: RequestConfig) { diff --git a/ui-ngx/src/app/core/http/device.service.ts b/ui-ngx/src/app/core/http/device.service.ts index 1e3a304774..c93c658d8c 100644 --- a/ui-ngx/src/app/core/http/device.service.ts +++ b/ui-ngx/src/app/core/http/device.service.ts @@ -15,8 +15,9 @@ /// import { Injectable } from '@angular/core'; -import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; -import { Observable, ReplaySubject } from 'rxjs'; +import { createDefaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { catchError, Observable, of, ReplaySubject, throwError, timeout } from 'rxjs'; +import { map, switchMap } from "rxjs/operators"; import { HttpClient } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; import { PageData } from '@shared/models/page/page-data'; @@ -28,13 +29,15 @@ import { DeviceInfo, DeviceInfoQuery, DeviceSearchQuery, - PublishTelemetryCommand + PublishTelemetryCommand, + SaveDeviceParams } from '@shared/models/device.models'; import { EntitySubtype } from '@shared/models/entity-type.models'; import { AuthService } from '@core/auth/auth.service'; import { BulkImportRequest, BulkImportResult } from '@shared/import-export/import-export.models'; import { PersistentRpc, RpcStatus } from '@shared/models/rpc.models'; import { ResourcesService } from '@core/services/resources.service'; +import { SaveEntityParams } from '@shared/models/entity.models'; @Injectable({ providedIn: 'root' @@ -87,15 +90,19 @@ export class DeviceService { return this.http.get(`/api/device/info/${deviceId}`, defaultHttpOptionsFromConfig(config)); } - public saveDevice(device: Device, config?: RequestConfig): Observable { - return this.http.post('/api/device', device, defaultHttpOptionsFromConfig(config)); + public saveDevice(device: Device, config?: RequestConfig): Observable; + public saveDevice(device: Device, saveParams?: SaveDeviceParams, config?: RequestConfig): Observable; + public saveDevice(device: Device, saveParamsOrConfig?: SaveDeviceParams | RequestConfig, config?: RequestConfig): Observable { + return this.http.post('/api/device', device, createDefaultHttpOptions(saveParamsOrConfig, config)); } - public saveDeviceWithCredentials(device: Device, credentials: DeviceCredentials, config?: RequestConfig): Observable { + public saveDeviceWithCredentials(device: Device, credentials: DeviceCredentials, config?: RequestConfig): Observable; + public saveDeviceWithCredentials(device: Device, credentials: DeviceCredentials, saveParams: SaveEntityParams, config?: RequestConfig): Observable; + public saveDeviceWithCredentials(device: Device, credentials: DeviceCredentials, saveParamsOrConfig?: SaveEntityParams | RequestConfig, config?: RequestConfig): Observable { return this.http.post('/api/device-with-credentials', { device, credentials - }, defaultHttpOptionsFromConfig(config)); + }, createDefaultHttpOptions(saveParamsOrConfig, config)); } public deleteDevice(deviceId: string, config?: RequestConfig) { @@ -219,4 +226,71 @@ export class DeviceService { public downloadGatewayDockerComposeFile(deviceId: string): Observable { return this.resourcesService.downloadResource(`/api/device-connectivity/gateway-launch/${deviceId}/docker-compose/download`); } + + public rebootDevice(deviceId: string, isBootstrapServer: boolean, config?: RequestConfig): Observable<{ + result: string, + msg: string + }> { + const rebootName = isBootstrapServer ? 'Bootstrap-Request Trigger' : 'Registration Update Trigger'; + return this.sendTwoWayRpcCommand(deviceId, {method: 'DiscoverAll'}, config).pipe( + timeout(10000), + switchMap((response: any) => { + if (response.result && response.result.toUpperCase() === 'CONTENT') { + const resourceId = isBootstrapServer ? 9 : 8; + const resourcePath = `/1/0/${resourceId}`; + return this.rebootTrigger(deviceId, resourcePath, config).pipe( + map((responseReboot: any) => { + if (responseReboot.result === 'CHANGED') { + return { + result: 'SUCCESS', + msg: `"${rebootName}" - Started Successfully.` + }; + } else { + return { + result: 'ERROR', + msg: `"${rebootName}" failed:
    ${JSON.stringify(responseReboot, null, 2)}
    ` + } + } + }), + catchError(err => + of({ + result: 'ERROR', + msg: `"${rebootName}" failed.
    Error: ${err.message || err}` + }) + ) + ); + } else { + return of({ + result: 'ERROR', + msg: `"${rebootName}" failed.
    Bad registration device with id = ${deviceId}.
    "DiscoverAll" - RPC result is not "CONTENT"` + }); + } + }), + catchError(err => + of({ + result: 'ERROR', + msg: `"${rebootName}" failed.
    Bad registration device with id = ${deviceId}.
    Error: ${err.message || err}` + }) + ) + ); + } + + private rebootTrigger(deviceId: string, resourcePath: string, config?: RequestConfig): Observable<{ result: string, msg?: string }> { + return this.sendTwoWayRpcCommand(deviceId, {method: 'Execute', params: {id: resourcePath}}, config).pipe( + timeout(10000), + map(res => { + if (res?.result?.toUpperCase() === 'CHANGED') { + return {result: 'CHANGED'}; + } else { + return { + result: `${res?.result}`, + msg: `${res?.error}` + } + } + }), + catchError(err => { + return throwError(() => err); + }) + ); + } } diff --git a/ui-ngx/src/app/core/http/entity-view.service.ts b/ui-ngx/src/app/core/http/entity-view.service.ts index ffd17d9a49..7285c5420f 100644 --- a/ui-ngx/src/app/core/http/entity-view.service.ts +++ b/ui-ngx/src/app/core/http/entity-view.service.ts @@ -15,13 +15,14 @@ /// import { Injectable } from '@angular/core'; -import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { createDefaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; import { PageData } from '@shared/models/page/page-data'; import { EntitySubtype } from '@app/shared/models/entity-type.models'; import { EntityView, EntityViewInfo, EntityViewSearchQuery } from '@app/shared/models/entity-view.models'; +import { SaveEntityParams } from '@shared/models/entity.models'; @Injectable({ providedIn: 'root' @@ -51,8 +52,10 @@ export class EntityViewService { return this.http.get(`/api/entityView/info/${entityViewId}`, defaultHttpOptionsFromConfig(config)); } - public saveEntityView(entityView: EntityView, config?: RequestConfig): Observable { - return this.http.post('/api/entityView', entityView, defaultHttpOptionsFromConfig(config)); + public saveEntityView(entityView: EntityView, config?: RequestConfig): Observable; + public saveEntityView(entityView: EntityView, saveParams: SaveEntityParams, config?: RequestConfig): Observable; + public saveEntityView(entityView: EntityView, saveParamsOrConfig?: SaveEntityParams | RequestConfig, config?: RequestConfig): Observable { + return this.http.post('/api/entityView', entityView, createDefaultHttpOptions(saveParamsOrConfig, config)); } public deleteEntityView(entityViewId: string, config?: RequestConfig) { diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 572d6cc473..5cd4167afa 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -100,6 +100,7 @@ import { OAuth2Service } from '@core/http/oauth2.service'; import { MobileAppService } from '@core/http/mobile-app.service'; import { PlatformType } from '@shared/models/oauth2.models'; import { AiModelService } from '@core/http/ai-model.service'; +import { ResourceType } from "@shared/models/resource.models"; @Injectable({ providedIn: 'root' @@ -297,6 +298,9 @@ export class EntityService { (id) => this.ruleChainService.getRuleChain(id, config), entityIds); break; + case EntityType.TB_RESOURCE: + observable = this.resourceService.getResourcesByIds(entityIds, config); + break; } return observable; } @@ -472,7 +476,7 @@ export class EntityService { break; case EntityType.TB_RESOURCE: pageLink.sortOrder.property = 'title'; - entitiesObservable = this.resourceService.getTenantResources(pageLink, config); + entitiesObservable = this.resourceService.getResources(pageLink, subType as ResourceType, null, config); break; case EntityType.QUEUE_STATS: pageLink.sortOrder.property = 'createdTime'; @@ -813,6 +817,7 @@ export class EntityService { switch (entityType) { case EntityType.USER: entityFieldKeys.push(entityFields.name.keyName); + entityFieldKeys.push(entityFields.displayName.keyName); entityFieldKeys.push(entityFields.email.keyName); entityFieldKeys.push(entityFields.firstName.keyName); entityFieldKeys.push(entityFields.lastName.keyName); @@ -842,6 +847,7 @@ export class EntityService { case EntityType.EDGE: case EntityType.ASSET: entityFieldKeys.push(entityFields.name.keyName); + entityFieldKeys.push(entityFields.displayName.keyName); entityFieldKeys.push(entityFields.type.keyName); entityFieldKeys.push(entityFields.label.keyName); entityFieldKeys.push(entityFields.ownerName.keyName); diff --git a/ui-ngx/src/app/core/http/git-hub.service.ts b/ui-ngx/src/app/core/http/git-hub.service.ts new file mode 100644 index 0000000000..b6a927dab3 --- /dev/null +++ b/ui-ngx/src/app/core/http/git-hub.service.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Injectable } from '@angular/core'; +import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { Observable, of } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; +import { catchError, map } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class GitHubService { + + constructor( + private http: HttpClient + ) { } + + public getGitHubStar(config?: RequestConfig): Observable { + return this.http.get('https://api.github.com/repos/thingsboard/thingsboard', defaultHttpOptionsFromConfig(config)).pipe( + catchError(() => of({})), + map((res: any) => res?.stargazers_count ?? 0) + ) + } +} diff --git a/ui-ngx/src/app/core/http/http-utils.ts b/ui-ngx/src/app/core/http/http-utils.ts index 787abcfbf3..3d28f6ddf4 100644 --- a/ui-ngx/src/app/core/http/http-utils.ts +++ b/ui-ngx/src/app/core/http/http-utils.ts @@ -18,32 +18,81 @@ import { InterceptorHttpParams } from '../interceptors/interceptor-http-params'; import { HttpHeaders } from '@angular/common/http'; import { InterceptorConfig } from '../interceptors/interceptor-config'; +export type QueryParams = { [param:string]: any }; + export interface RequestConfig { ignoreLoading?: boolean; ignoreErrors?: boolean; resendRequest?: boolean; + queryParams?: QueryParams; +} + +export function hasRequestConfig(config?: any): boolean { + if (!config) { + return false; + } + return config.hasOwnProperty('ignoreLoading') || config.hasOwnProperty('ignoreErrors') || config.hasOwnProperty('resendRequest') || config.hasOwnProperty('queryParams'); +} + +export function createDefaultHttpOptions(queryParamsOrConfig?: QueryParams | RequestConfig, config?: RequestConfig) { + if (hasRequestConfig(queryParamsOrConfig)) { + return defaultHttpOptionsFromConfig(queryParamsOrConfig as RequestConfig); + } + return defaultHttpOptionsFromParams(queryParamsOrConfig as QueryParams, config); +} + +export function defaultHttpOptionsFromParams(queryParams?: QueryParams, config?: RequestConfig) { + const finalConfig = { + ...config, + ...(queryParams && { queryParams }), + }; + return defaultHttpOptionsFromConfig(finalConfig); } export function defaultHttpOptionsFromConfig(config?: RequestConfig) { if (!config) { config = {}; } - return defaultHttpOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest); + return defaultHttpOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest, config.queryParams); } export function defaultHttpOptions(ignoreLoading: boolean = false, ignoreErrors: boolean = false, - resendRequest: boolean = false) { + resendRequest: boolean = false, + queryParams?: QueryParams) { + const cleanedParams = cleanQueryParams(queryParams); + return { headers: new HttpHeaders({'Content-Type': 'application/json'}), - params: new InterceptorHttpParams(new InterceptorConfig(ignoreLoading, ignoreErrors, resendRequest)) + params: new InterceptorHttpParams(new InterceptorConfig(ignoreLoading, ignoreErrors, resendRequest), cleanedParams) }; } export function defaultHttpUploadOptions(ignoreLoading: boolean = false, ignoreErrors: boolean = false, - resendRequest: boolean = false) { + resendRequest: boolean = false, + queryParams?: QueryParams) { + const cleanedParams = cleanQueryParams(queryParams); + return { - params: new InterceptorHttpParams(new InterceptorConfig(ignoreLoading, ignoreErrors, resendRequest)) + params: new InterceptorHttpParams(new InterceptorConfig(ignoreLoading, ignoreErrors, resendRequest), cleanedParams) }; } + +function cleanQueryParams(params?: QueryParams): QueryParams | undefined { + if (!params) { + return undefined; + } + + const entries = Object.entries(params); + + const cleanedEntries = entries.filter( + ([_, value]) => value !== null && value !== undefined + ); + + if (!cleanedEntries.length) { + return undefined; + } + + return Object.fromEntries(cleanedEntries); +} diff --git a/ui-ngx/src/app/core/http/queue.service.ts b/ui-ngx/src/app/core/http/queue.service.ts index d9bc19ef62..651d26c9dc 100644 --- a/ui-ngx/src/app/core/http/queue.service.ts +++ b/ui-ngx/src/app/core/http/queue.service.ts @@ -75,7 +75,7 @@ export class QueueService { } public getQueueStatisticsByIds(queueStatIds: Array, config?: RequestConfig): Observable> { - return this.http.get>(`/api/queueStats?QueueStatsIds=${queueStatIds.join(',')}`, + return this.http.get>(`/api/queueStats?queueStatsIds=${queueStatIds.join(',')}`, defaultHttpOptionsFromConfig(config)).pipe( map(queueStats => queueStats.map(queueStat => this.parseQueueStatName(queueStat)) ) diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index 615b721b97..168c63b3b1 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -48,7 +48,7 @@ export class ResourceService { } public getTenantResources(pageLink: PageLink, config?: RequestConfig): Observable> { - return this.http.get>(`/api/resource/tenant${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + return this.http.get>(`/api/resource/tenant${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)) } public getResource(resourceId: string, config?: RequestConfig): Observable { @@ -94,4 +94,9 @@ export class ResourceService { return this.http.delete(`/api/resource/${resourceId}?force=${force}`, defaultHttpOptionsFromConfig(config)); } + public getResourcesByIds(ids: string[], config?: RequestConfig): Observable> { + return this.http.get>(`/api/resource?resourceIds=${ids.join(',')}`, + defaultHttpOptionsFromConfig(config)); + } + } diff --git a/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts b/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts index 12d3c78817..dccda985b4 100644 --- a/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts +++ b/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts @@ -30,6 +30,7 @@ import { DialogService } from '@core/services/dialog.service'; import { TranslateService } from '@ngx-translate/core'; import { parseHttpErrorMessage } from '@core/utils'; import { getInterceptorConfig } from './interceptor.util'; +import { DomSanitizer } from '@angular/platform-browser'; const tmpHeaders = {}; @@ -46,6 +47,7 @@ export class GlobalHttpInterceptor implements HttpInterceptor { private dialogService: DialogService, private translate: TranslateService, private authService: AuthService, + private sanitizer: DomSanitizer ) {} intercept(req: HttpRequest, next: HttpHandler): Observable> { @@ -129,7 +131,7 @@ export class GlobalHttpInterceptor implements HttpInterceptor { } if (unhandled && !ignoreErrors) { - const errorMessageWithTimeout = parseHttpErrorMessage(errorResponse, this.translate, req.responseType); + const errorMessageWithTimeout = parseHttpErrorMessage(errorResponse, this.translate, req.responseType, this.sanitizer); this.showError(errorMessageWithTimeout.message, errorMessageWithTimeout.timeout); } return throwError(() => errorResponse); diff --git a/ui-ngx/src/app/core/interceptors/interceptor-http-params.ts b/ui-ngx/src/app/core/interceptors/interceptor-http-params.ts index 1d9cbdddaa..ab75102464 100644 --- a/ui-ngx/src/app/core/interceptors/interceptor-http-params.ts +++ b/ui-ngx/src/app/core/interceptors/interceptor-http-params.ts @@ -20,7 +20,7 @@ import { InterceptorConfig } from './interceptor-config'; export class InterceptorHttpParams extends HttpParams { constructor( public interceptorConfig: InterceptorConfig, - params?: { [param: string]: string | string[] } + params?: { [param: string]: string | number | boolean | ReadonlyArray; } ) { super({ fromObject: params }); } diff --git a/ui-ngx/src/app/core/services/mobile.service.ts b/ui-ngx/src/app/core/services/mobile.service.ts index 6fcb7c3602..aceb564ec2 100644 --- a/ui-ngx/src/app/core/services/mobile.service.ts +++ b/ui-ngx/src/app/core/services/mobile.service.ts @@ -106,9 +106,9 @@ export class MobileService { } } - public handleMobileNavigation(path?: string, params?: Params) { + public handleMobileNavigation(path?: string, params?: Params, queryParams?: Params) { if (this.mobileApp) { - this.mobileChannel.callHandler(navigationHandler, path, params); + this.mobileChannel.callHandler(navigationHandler, path, params, queryParams); } } diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index 198737bfed..c68f26d56f 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -32,8 +32,12 @@ import { isNotEmptyTbFunction, TbFunction } from '@shared/models/js-function.models'; +import { DomSanitizer } from '@angular/platform-browser'; +import { SecurityContext } from '@angular/core'; +import { AbstractControl, ValidationErrors, Validators } from '@angular/forms'; const varsRegex = /\${([^}]*)}/g; +const emailRegex = /^[A-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i; export function onParentScrollOrWindowResize(el: Node): Observable { const scrollSubject = new Subject(); @@ -854,7 +858,7 @@ export function getEntityDetailsPageURL(id: string, entityType: EntityType): str } export function parseHttpErrorMessage(errorResponse: HttpErrorResponse, - translate: TranslateService, responseType?: string): {message: string; timeout: number} { + translate: TranslateService, responseType?: string, sanitizer?:DomSanitizer): {message: string; timeout: number} { let error = null; let errorMessage: string; let timeout = 0; @@ -882,6 +886,9 @@ export function parseHttpErrorMessage(errorResponse: HttpErrorResponse, errorText += errorKey ? translate.instant(errorKey) : errorResponse.statusText; errorMessage = errorText; } + if(sanitizer) { + errorMessage = sanitizer.sanitize(SecurityContext.HTML,errorMessage); + } return {message: errorMessage, timeout}; } @@ -1028,3 +1035,11 @@ export const trimDefaultValues = (input: Record, defaults: Record { + if (isUndefinedOrNull(control.value) || (typeof control.value === 'string' && control.value.length === 0)) { + return null; + } + return emailRegex.test(control.value) ? null : {email: true}; +}; + diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html index 5f9c189399..9d192a0d14 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html @@ -55,31 +55,34 @@ -
    +
    @if (providerFieldsList.includes('personalAccessToken')) { ai-models.personal-access-token - + {{ 'ai-models.personal-access-token-required' | translate }} } @if (providerFieldsList.includes('projectId')) { - + ai-models.project-id - + {{ 'ai-models.project-id-required' | translate }} } @if (providerFieldsList.includes('location')) { - + ai-models.location - + {{ 'ai-models.location-required' | translate }} @@ -98,44 +101,58 @@ } @if (providerFieldsList.includes('endpoint')) { - + ai-models.endpoint - + {{ 'ai-models.endpoint-required' | translate }} } @if (providerFieldsList.includes('serviceVersion')) { - + ai-models.service-version } + @if (providerFieldsList.includes('baseUrl')) { + + ai-models.baseurl + + + {{ 'ai-models.baseurl-required' | translate }} + + + } @if (providerFieldsList.includes('apiKey')) { ai-models.api-key - + - - {{ 'ai-models.api-key-required' | translate }} + + {{ ( provider === aiProvider.OPENAI ? 'ai-models.api-key-open-ai-required' : 'ai-models.api-key-required') | translate }} } @if (providerFieldsList.includes('region')) { - + ai-models.region - + {{ 'ai-models.region-required' | translate }} } @if (providerFieldsList.includes('accessKeyId')) { - + ai-models.access-key-id - + {{ 'ai-models.access-key-id-required' | translate }} @@ -145,11 +162,58 @@ ai-models.secret-access-key - + {{ 'ai-models.secret-access-key-required' | translate }} } + @if (provider === aiProvider.OLLAMA) { +
    +
    +
    + {{ 'ai-models.authentication' | translate }} +
    + + {{ 'ai-models.authentication-type.none' | translate }} + {{ 'ai-models.authentication-type.basic' | translate }} + {{ 'ai-models.authentication-type.token' | translate }} + +
    +
    + @if (aiModelForms.get('configuration.providerConfig.auth.type').value === AuthenticationType.BASIC) { + + ai-models.username + + + {{ 'ai-models.username-required' | translate }} + + + + ai-models.password + + + + {{ 'ai-models.password-required' | translate }} + + + } + @if (aiModelForms.get('configuration.providerConfig.auth.type').value === AuthenticationType.TOKEN) { + + ai-models.token + + + + {{ 'ai-models.token-required' | translate }} + + + } +
    +
    + }
    @@ -161,6 +225,7 @@ additionalClass="tb-suffix-show-on-hover" appearance="outline" panelWidth="" + showInlineError required [label]="(provider === aiProvider.AZURE_OPENAI ? 'ai-models.deployment-name': 'ai-models.model-id') | translate" [errorText]="(provider === aiProvider.AZURE_OPENAI ? 'ai-models.deployment-name-required': 'ai-models.model-id-required') | translate" @@ -255,15 +320,18 @@
    - - warning - + type="number" step="1" placeholder="{{ 'ai-models.set' | translate }}"> + + + } + @if (modelFieldsList.includes('contextLength')) { +
    +
    + {{ 'ai-models.context-length' | translate }} +
    + +
    } diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts index b74c113d9f..5511160193 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts @@ -30,6 +30,7 @@ import { AiModelMap, AiProvider, AiProviderTranslations, + AuthenticationType, ModelType, ProviderFieldsAllList } from '@shared/models/ai-model.models'; @@ -37,6 +38,7 @@ import { AiModelService } from '@core/http/ai-model.service'; import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; import { map } from 'rxjs/operators'; import { deepTrim } from '@core/utils'; +import { TranslateService } from '@ngx-translate/core'; export interface AIModelDialogData { AIModel?: AiModel; @@ -63,18 +65,26 @@ export class AIModelDialogComponent extends DialogComponent, protected router: Router, protected dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: AIModelDialogData, private fb: FormBuilder, private aiModelService: AiModelService, + private translate: TranslateService, private dialog: MatDialog) { super(store, router, dialogRef); @@ -90,17 +100,24 @@ export class AIModelDialogComponent extends DialogComponent { + if (this.provider === AiProvider.OPENAI) { + this.updateApiKeyValidatorForOpenAIProvider(url); + } + }); + + this.aiModelForms.get('configuration.providerConfig.auth.type').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((type: AuthenticationType) => { + this.getAuthenticationHint(type); + this.aiModelForms.get('configuration.providerConfig.auth.username').disable(); + this.aiModelForms.get('configuration.providerConfig.auth.password').disable(); + this.aiModelForms.get('configuration.providerConfig.auth.token').disable(); + if (type === AuthenticationType.BASIC) { + this.aiModelForms.get('configuration.providerConfig.auth.username').enable(); + this.aiModelForms.get('configuration.providerConfig.auth.password').enable(); + } + if (type === AuthenticationType.TOKEN) { + this.aiModelForms.get('configuration.providerConfig.auth.token').enable(); + } + }); this.updateValidation(this.provider); } @@ -135,6 +181,27 @@ export class AIModelDialogComponent extends DialogComponent { if (AiModelMap.get(provider).providerFieldsList.includes(key)) { @@ -142,7 +209,13 @@ export class AIModelDialogComponent extends DialogComponent +
    + +

    {{ 'alarm-rule.edit-alarm-rule-additional-info' | translate }}

    + +
    +
    +
    + + + + +
    +
    +
    + + @if (!data.readonly) { + + } +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.ts new file mode 100644 index 0000000000..8a55f52678 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.ts @@ -0,0 +1,60 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 } 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, FormControl } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; + +export interface AlarmRuleDetailsDialogData { + alarmDetails: string; + readonly: boolean; +} + +@Component({ + selector: 'tb-edit-alarm-details-dialog', + templateUrl: './alarm-rule-details-dialog.component.html', + providers: [], + styleUrls: ['./cf-alarm-rules-dialog.component.scss'], +}) +export class AlarmRuleDetailsDialogComponent extends DialogComponent { + + alarmDetailsControl: FormControl; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AlarmRuleDetailsDialogData, + public dialogRef: MatDialogRef, + private fb: FormBuilder) { + super(store, router, dialogRef); + + this.alarmDetailsControl = this.fb.control(this.data.alarmDetails); + if (this.data.readonly) { + this.alarmDetailsControl.disable(); + } + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.dialogRef.close(this.alarmDetailsControl.value); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html new file mode 100644 index 0000000000..e0f1a052d5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html @@ -0,0 +1,163 @@ + +
    + +

    {{ 'alarm-rule.alarm-rule' | translate}}

    + + +
    +
    +
    +
    +
    {{ 'common.general' | translate }}
    +
    + + {{ 'alarm-rule.alarm-type' | translate }} + + @if (fieldFormGroup.get('name').errors && fieldFormGroup.get('name').touched) { + + @if (fieldFormGroup.get('name').hasError('required')) { + {{ 'alarm-rule.alarm-type-required' | translate }} + } @else if (fieldFormGroup.get('name').hasError('pattern')) { + {{ 'alarm-rule.alarm-type-pattern' | translate }} + } @else if (fieldFormGroup.get('name').hasError('maxlength')) { + {{ 'alarm-rule.alarm-type-max-length' | translate }} + } + + } + + +
    +
    + +
    +
    {{ 'calculated-fields.arguments' | translate }}
    + +
    +
    +
    {{ 'alarm-rule.create-alarm-rules' | translate }}
    +
    + + +
    +
    +
    +
    {{ 'alarm-rule.clear-alarm-rule' | translate }}
    +
    +
    + + +
    + +
    +
    + alarm-rule.no-clear-alarm-rule +
    +
    + +
    +
    +
    + + + {{ 'alarm-rule.advanced-settings' | translate }} + + +
    + + {{ 'alarm-rule.propagate-alarm' | translate }} + +
    + @if (configFormGroup.get('propagate').value) { + + alarm-rule.alarm-rule-relation-types-list + + + {{key}} + close + + + + + + } +
    + + {{ 'alarm-rule.propagate-alarm-to-owner' | translate }} + +
    +
    + + {{ 'alarm-rule.propagate-alarm-to-tenant' | translate }} + +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.scss new file mode 100644 index 0000000000..04e46fd3d0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.scss @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.calculated-field-dialog-container { + width: 869px; + max-width: 100%; +} + +.clear-alarm-rule { + border: 1px solid rgba(0, 0, 0, 0.12); + border-left-width: 4px; + border-left-color: green; + border-radius: 4px; + padding: 8px; + min-width: 0; +} + +.button-icon { + color: rgba(0, 0, 0, 0.38); + min-width: 40px; +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts new file mode 100644 index 0000000000..0ea3bd036e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts @@ -0,0 +1,179 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, DestroyRef, Inject, ViewEncapsulation } 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, FormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { CalculatedField, CalculatedFieldArgument, CalculatedFieldType } from '@shared/models/calculated-field.models'; +import { oneSpaceInsideRegex } from '@shared/models/regex.constants'; +import { EntityType } from '@shared/models/entity-type.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ScriptLanguage } from '@shared/models/rule-node.models'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { EntityId } from '@shared/models/id/entity-id'; +import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; +import { COMMA, ENTER, SEMICOLON } from "@angular/cdk/keycodes"; +import { MatChipInputEvent } from "@angular/material/chips"; +import { AlarmRule, AlarmRuleConditionType, AlarmRuleExpressionType } from "@shared/models/alarm-rule.models"; +import { deepTrim } from "@core/utils"; + +export interface AlarmRuleDialogData { + value?: CalculatedField; + buttonTitle: string; + entityId: EntityId; + tenantId: string; + entityName?: string; + ownerId: EntityId; + additionalDebugActionConfig: AdditionalDebugActionConfig<(calculatedField: CalculatedField) => void>; + isDirty?: boolean; +} + +@Component({ + selector: 'tb-alarm-rule-dialog', + templateUrl: './alarm-rule-dialog.component.html', + styleUrls: ['./alarm-rule-dialog.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class AlarmRuleDialogComponent extends DialogComponent { + + fieldFormGroup = this.fb.group({ + name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], + type: [CalculatedFieldType.ALARM], + debugSettings: [], + configuration: this.fb.group({ + arguments: this.fb.control({}), + propagate: [false], + propagateToOwner: [false], + propagateToTenant: [false], + propagateRelationTypes: [null], + createRules: [null], + clearRule: [null], + }), + }); + + additionalDebugActionConfig = this.data.value?.id ? { + ...this.data.additionalDebugActionConfig, + action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }), + } : null; + + readonly EntityType = EntityType; + readonly CalculatedFieldType = CalculatedFieldType; + readonly ScriptLanguage = ScriptLanguage; + + separatorKeysCodes = [ENTER, COMMA, SEMICOLON]; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AlarmRuleDialogData, + protected dialogRef: MatDialogRef, + private calculatedFieldsService: CalculatedFieldsService, + private destroyRef: DestroyRef, + private fb: FormBuilder) { + super(store, router, dialogRef); + this.observeIsLoading(); + this.applyDialogData(); + } + + get configFormGroup(): FormGroup { + return this.fieldFormGroup.get('configuration') as FormGroup; + } + + get arguments(): Record { + return this.fieldFormGroup.get('configuration.arguments').value; + } + + public removeClearAlarmRule() { + this.configFormGroup.patchValue({clearRule: null}); + this.fieldFormGroup.markAsDirty(); + } + + public addClearAlarmRule() { + const clearAlarmRule: AlarmRule = { + condition: { + type: AlarmRuleConditionType.SIMPLE, + expression: { + type: AlarmRuleExpressionType.SIMPLE + } + } + }; + this.configFormGroup.patchValue({clearRule: clearAlarmRule}); + } + + removeRelationType(key: string): void { + const keys: string[] = this.configFormGroup.get('propagateRelationTypes').value; + const index = keys.indexOf(key); + if (index >= 0) { + keys.splice(index, 1); + this.configFormGroup.get('propagateRelationTypes').setValue(keys); + } + } + + addRelationType(event: MatChipInputEvent): void { + const input = event.chipInput.inputElement; + let value = (event.value ?? '').trim(); + if (value) { + let keys: string[] = this.configFormGroup.get('propagateRelationTypes').value ?? []; + if (keys.indexOf(value) === -1) { + keys.push(value); + this.configFormGroup.get('propagateRelationTypes').setValue(keys); + } + } + if (input) { + input.value = ''; + } + } + + get fromGroupValue(): CalculatedField { + return deepTrim(this.fieldFormGroup.value as CalculatedField); + } + + cancel(): void { + this.dialogRef.close(null); + } + + add(): void { + if (this.fieldFormGroup.valid) { + const alarmRule = { entityId: this.data.entityId, ...(this.data.value ?? {}), ...this.fromGroupValue}; + alarmRule.configuration.type = CalculatedFieldType.ALARM; + + this.calculatedFieldsService.saveCalculatedField(alarmRule) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(calculatedField => this.dialogRef.close(calculatedField)); + } + } + + private applyDialogData(): void { + const { configuration = {}, type = CalculatedFieldType.ALARM, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {}; + this.fieldFormGroup.patchValue({ configuration, type, debugSettings, ...value }, {emitEvent: false}); + } + + private observeIsLoading(): void { + this.isLoading$.pipe(takeUntilDestroyed()).subscribe(loading => { + if (loading) { + this.fieldFormGroup.disable({emitEvent: false}); + } else { + this.fieldFormGroup.enable({emitEvent: false}); + if (this.data.isDirty) { + this.fieldFormGroup.markAsDirty(); + } + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts new file mode 100644 index 0000000000..7acfca7b0d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts @@ -0,0 +1,80 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { AlarmRuleDialogComponent } from "@home/components/alarm-rules/alarm-rule-dialog.component"; +import { CreateCfAlarmRulesComponent } from "@home/components/alarm-rules/create-cf-alarm-rules.component"; +import { CfAlarmRuleComponent } from "@home/components/alarm-rules/cf-alarm-rule.component"; +import { CfAlarmRuleConditionComponent } from "@home/components/alarm-rules/cf-alarm-rule-condition.component"; +import { + CfAlarmRuleConditionDialogComponent +} from "@home/components/alarm-rules/cf-alarm-rule-condition-dialog.component"; +import { CfAlarmScheduleComponent } from "@home/components/alarm-rules/cf-alarm-schedule.component"; +import { CfAlarmScheduleDialogComponent } from "@home/components/alarm-rules/cf-alarm-schedule-dialog.component"; +import { + EntityDebugSettingsButtonComponent +} from "@home/components/entity/debug/entity-debug-settings-button.component"; +import { AlarmRuleFilterTextComponent } from "@home/components/alarm-rules/filter/alarm-rule-filter-text.component"; +import { + CalculatedFieldArgumentsTableModule +} from "@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module"; +import { + AlarmRuleFilterPredicateListComponent +} from "@home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component"; +import { + AlarmRuleFilterPredicateComponent +} from "@home/components/alarm-rules/filter/alarm-rule-filter-predicate.component"; +import { + AlarmRuleFilterPredicateValueComponent +} from "@home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component"; +import { + AlarmRuleComplexFilterPredicateDialogComponent +} from "@home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component"; +import { AlarmRuleFilterListComponent } from "@home/components/alarm-rules/filter/alarm-rule-filter-list.component"; +import { AlarmRuleFilterDialogComponent } from "@home/components/alarm-rules/filter/alarm-rule-filter-dialog.component"; +import { AlarmRuleDetailsDialogComponent } from "@home/components/alarm-rules/alarm-rule-details-dialog.component"; + +@NgModule({ + declarations: [ + AlarmRuleDialogComponent, + CreateCfAlarmRulesComponent, + CfAlarmRuleComponent, + CfAlarmRuleConditionComponent, + CfAlarmRuleConditionDialogComponent, + CfAlarmScheduleComponent, + CfAlarmScheduleDialogComponent, + AlarmRuleFilterTextComponent, + AlarmRuleFilterListComponent, + AlarmRuleFilterDialogComponent, + AlarmRuleFilterPredicateListComponent, + AlarmRuleFilterPredicateComponent, + AlarmRuleFilterPredicateValueComponent, + AlarmRuleComplexFilterPredicateDialogComponent, + AlarmRuleDetailsDialogComponent, + ], + imports: [ + CommonModule, + SharedModule, + EntityDebugSettingsButtonComponent, + CalculatedFieldArgumentsTableModule + ], + exports: [ + AlarmRuleDialogComponent, + ] +}) +export class AlarmRuleModule { } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts new file mode 100644 index 0000000000..5167e32853 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts @@ -0,0 +1,281 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + checkBoxCell, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { TranslateService } from '@ngx-translate/core'; +import { Direction } from '@shared/models/page/sort-order'; +import { MatDialog } from '@angular/material/dialog'; +import { PageLink } from '@shared/models/page/page-link'; +import { Observable, of } from 'rxjs'; +import { PageData } from '@shared/models/page/page-data'; +import { EntityId } from '@shared/models/id/entity-id'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { DestroyRef, Renderer2 } from '@angular/core'; +import { EntityDebugSettings } from '@shared/models/entity.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { catchError, filter, switchMap } from 'rxjs/operators'; +import { + ArgumentEntityType, + CalculatedField, + CalculatedFieldAlarmRule, + CalculatedFieldType, +} from '@shared/models/calculated-field.models'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; +import { DatePipe } from '@angular/common'; +import { + AlarmRuleDialogComponent, + AlarmRuleDialogData +} from "@home/components/alarm-rules/alarm-rule-dialog.component"; +import { + CalculatedFieldDebugDialogComponent, + CalculatedFieldDebugDialogData +} from "@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component"; +import { AlarmSeverity, alarmSeverityTranslations } from "@shared/models/alarm.models"; +import { UtilsService } from "@core/services/utils.service"; + +export class AlarmRulesTableConfig extends EntityTableConfig { + + readonly tenantId = getCurrentAuthUser(this.store).tenantId; + additionalDebugActionConfig = { + title: this.translate.instant('calculated-fields.see-debug-events'), + action: (calculatedField: CalculatedField) => this.openDebugEventsDialog.call(this, calculatedField), + }; + + constructor(private calculatedFieldsService: CalculatedFieldsService, + private translate: TranslateService, + private dialog: MatDialog, + private datePipe: DatePipe, + public entityId: EntityId = null, + private store: Store, + private destroyRef: DestroyRef, + private renderer: Renderer2, + public entityName: string, + private ownerId: EntityId = null, + private importExportService: ImportExportService, + private entityDebugSettingsService: EntityDebugSettingsService, + private utilsService: UtilsService, + ) { + super(); + this.tableTitle = this.translate.instant('alarm-rule.alarm-rules'); + this.detailsPanelEnabled = false; + this.pageMode = false; + this.entityType = EntityType.CALCULATED_FIELD; + this.entityTranslations = { + type: 'alarm-rule.alarm-rule', + typePlural: 'alarm-rule.alarm-rules', + list: 'alarm-rule.list', + add: 'action.add', + noEntities: 'alarm-rule.no-found', + search: 'action.search', + selectedEntities: 'alarm-rule.selected-fields' + }; + + this.entitiesFetchFunction = (pageLink: PageLink) => this.fetchCalculatedFields(pageLink); + this.addEntity = this.getCalculatedAlarmDialog.bind(this); + this.deleteEntityTitle = (field: CalculatedField) => this.translate.instant('alarm-rule.delete-title', {title: field.name}); + this.deleteEntityContent = () => this.translate.instant('alarm-rule.delete-text'); + this.deleteEntitiesTitle = count => this.translate.instant('alarm-rule.delete-multiple-title', {count}); + this.deleteEntitiesContent = () => this.translate.instant('alarm-rule.delete-multiple-text'); + this.deleteEntity = id => this.calculatedFieldsService.deleteCalculatedField(id.id); + this.addActionDescriptors = [ + { + name: this.translate.instant('alarm-rule.create'), + icon: 'insert_drive_file', + isEnabled: () => true, + onAction: ($event) => this.getTable().addEntity($event) + }, + { + name: this.translate.instant('alarm-rule.import'), + icon: 'file_upload', + isEnabled: () => true, + onAction: () => this.importCalculatedField() + } + ]; + + this.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; + this.columns.push(new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px')); + this.columns.push(new EntityTableColumn('name', 'alarm-rule.alarm-type', '33%', + entity => this.utilsService.customTranslation(entity.name, entity.name))); + this.columns.push(new EntityTableColumn('createRule', 'alarm-rule.severities', '67%', + entity => Object.keys(entity.configuration.createRules).map((severity) => this.translate.instant(alarmSeverityTranslations.get(severity as AlarmSeverity))).join(', '), + () => ({}), false)); + this.columns.push(new EntityTableColumn('clearRule', 'alarm-rule.cleared', '70px', + entity => checkBoxCell(!!entity.configuration.clearRule), ()=> { return {padding: 0, textAlign: 'center'}}, false)); + + this.cellActionDescriptors.push( + { + name: this.translate.instant('action.export'), + icon: 'file_download', + isEnabled: () => true, + onAction: (event$, entity) => this.exportAlarmRule(event$, entity), + }, + { + name: this.translate.instant('entity-view.events'), + icon: 'mdi:clipboard-text-clock', + isEnabled: () => true, + onAction: (_, entity) => this.openDebugEventsDialog(entity), + }, + { + name: '', + nameFunction: entity => this.entityDebugSettingsService.getDebugConfigLabel(entity?.debugSettings), + icon: 'mdi:bug', + isEnabled: () => true, + iconFunction: ({ debugSettings }) => this.entityDebugSettingsService.isDebugActive(debugSettings?.allEnabledUntil) || debugSettings?.failuresEnabled ? 'mdi:bug' : 'mdi:bug-outline', + onAction: ($event, entity) => this.onOpenDebugConfig($event, entity), + }, + { + name: this.translate.instant('action.edit'), + icon: 'edit', + isEnabled: () => true, + onAction: (_, entity) => this.editCalculatedField(entity), + } + ); + } + + fetchCalculatedFields(pageLink: PageLink): Observable> { + return this.calculatedFieldsService.getCalculatedFields(this.entityId, pageLink, CalculatedFieldType.ALARM); + } + + onOpenDebugConfig($event: Event, calculatedField: CalculatedField): void { + const { debugSettings = {}, id } = calculatedField; + const additionalActionConfig = { + ...this.additionalDebugActionConfig, + action: () => this.openDebugEventsDialog(calculatedField) + }; + if ($event) { + $event.stopPropagation(); + } + + const { viewContainerRef, renderer } = this.entityDebugSettingsService; + if (!viewContainerRef || !renderer) { + this.entityDebugSettingsService.viewContainerRef = this.getTable().viewContainerRef; + this.entityDebugSettingsService.renderer = this.renderer; + } + + this.entityDebugSettingsService.openDebugStrategyPanel({ + debugSettings, + debugConfig: { + entityType: EntityType.CALCULATED_FIELD, + entityLabel: 'alarm-rule.alarm-rule', + additionalActionConfig, + }, + onSettingsAppliedFn: settings => this.onDebugConfigChanged(id.id, settings) + }, $event.target as Element); + } + + private editCalculatedField(calculatedField: CalculatedField, isDirty = false): void { + this.getCalculatedAlarmDialog(calculatedField, 'action.apply', isDirty) + .subscribe((res) => { + if (res) { + this.updateData(); + } + }); + } + + private getCalculatedAlarmDialog(value?: CalculatedField, buttonTitle = 'action.add', isDirty = false): Observable { + return this.dialog.open(AlarmRuleDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + value, + buttonTitle, + entityId: this.entityId, + tenantId: this.tenantId, + entityName: this.entityName, + ownerId: this.ownerId, + additionalDebugActionConfig: this.additionalDebugActionConfig, + isDirty, + }, + enterAnimationDuration: isDirty ? 0 : null, + }) + .afterClosed() + .pipe(filter(Boolean)); + } + + private openDebugEventsDialog(calculatedField: CalculatedField): void { + this.dialog.open(CalculatedFieldDebugDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + tenantId: this.tenantId, + value: calculatedField, + getTestScriptDialogFn: null, + } + }) + .afterClosed() + .subscribe(); + } + + private exportAlarmRule($event: Event, calculatedField: CalculatedField): void { + if ($event) { + $event.stopPropagation(); + } + this.importExportService.exportCalculatedField(calculatedField.id.id); + } + + private importCalculatedField(): void { + this.importExportService.openCalculatedFieldImportDialog() + .pipe( + filter(Boolean), + switchMap(calculatedField => this.getCalculatedAlarmDialog(this.updateImportedCalculatedField(calculatedField), 'action.add', true)), + filter(Boolean), + switchMap(calculatedField => this.calculatedFieldsService.saveCalculatedField(calculatedField)), + filter(Boolean), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.updateData()); + } + + private updateImportedCalculatedField(calculatedField: CalculatedField): CalculatedField { + if (calculatedField.type === CalculatedFieldType.GEOFENCING) { + calculatedField.configuration.zoneGroups = Object.keys(calculatedField.configuration.zoneGroups).reduce((acc, key) => { + const arg = calculatedField.configuration.zoneGroups[key]; + acc[key] = arg.refEntityId?.entityType === ArgumentEntityType.Tenant + ? { ...arg, refEntityId: { id: this.tenantId, entityType: ArgumentEntityType.Tenant } } + : arg; + return acc; + }, {}); + } else { + calculatedField.configuration.arguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { + const arg = calculatedField.configuration.arguments[key]; + acc[key] = arg.refEntityId?.entityType === ArgumentEntityType.Tenant + ? { ...arg, refEntityId: { id: this.tenantId, entityType: ArgumentEntityType.Tenant } } + : arg; + return acc; + }, {}); + } + + return calculatedField; + } + + private onDebugConfigChanged(id: string, debugSettings: EntityDebugSettings): void { + this.calculatedFieldsService.getCalculatedFieldById(id).pipe( + switchMap(field => this.calculatedFieldsService.saveCalculatedField({ ...field, debugSettings })), + catchError(() => of(null)), + takeUntilDestroyed(this.destroyRef), + ).subscribe(() => this.updateData()); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html new file mode 100644 index 0000000000..e9eebb226c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html @@ -0,0 +1,20 @@ + +@if (alarmRulesTableConfig) { + +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.scss new file mode 100644 index 0000000000..0e0dacc392 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.scss @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 ::ng-deep { + tb-entities-table { + --mat-sidenav-content-background-color: white; + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts new file mode 100644 index 0000000000..4ade1c5461 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts @@ -0,0 +1,91 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, + DestroyRef, + effect, + input, + Renderer2, + ViewChild, +} from '@angular/core'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; +import { DatePipe } from '@angular/common'; +import { AlarmRulesTableConfig } from "@home/components/alarm-rules/alarm-rules-table-config"; +import { UtilsService } from "@core/services/utils.service"; + +@Component({ + selector: 'tb-alarm-rules-table', + templateUrl: './alarm-rules-table.component.html', + styleUrls: ['./alarm-rules-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [EntityDebugSettingsService] +}) +export class AlarmRulesTableComponent { + + @ViewChild(EntitiesTableComponent, {static: true}) entitiesTable: EntitiesTableComponent; + + active = input(); + entityId = input(); + entityName = input(); + ownerId = input(); + + alarmRulesTableConfig: AlarmRulesTableConfig; + + constructor(private calculatedFieldsService: CalculatedFieldsService, + private translate: TranslateService, + private dialog: MatDialog, + private store: Store, + private datePipe: DatePipe, + private cd: ChangeDetectorRef, + private renderer: Renderer2, + private importExportService: ImportExportService, + private entityDebugSettingsService: EntityDebugSettingsService, + private utilsService: UtilsService, + private destroyRef: DestroyRef) { + + effect(() => { + if (this.active()) { + this.alarmRulesTableConfig = new AlarmRulesTableConfig( + this.calculatedFieldsService, + this.translate, + this.dialog, + this.datePipe, + this.entityId(), + this.store, + this.destroyRef, + this.renderer, + this.entityName(), + this.ownerId(), + this.importExportService, + this.entityDebugSettingsService, + this.utilsService, + ); + this.cd.markForCheck(); + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html new file mode 100644 index 0000000000..d2f98ecd85 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html @@ -0,0 +1,188 @@ + +
    + +

    {{ (readonly ? 'alarm-rule.alarm-rule-condition' : 'alarm-rule.edit-alarm-rule-condition') | translate }}

    + +
    + + {{ 'alarm-rule.expression-type.simple' | translate }} + {{ 'alarm-rule.expression-type.tbel' | translate }} + +
    + +
    +
    +
    +
    + @if (conditionFormGroup.get('expression.type').value === AlarmRuleExpressionType.SIMPLE) { +
    +
    +
    {{ 'alarm-rule.argument-filters' | translate }}
    + + {{ complexOperationTranslationMap.get(ComplexOperation.AND) | translate }} + {{ complexOperationTranslationMap.get(ComplexOperation.OR) | translate }} + +
    + + +
    + } @else { +
    +
    + {{ 'alarm-rule.script' | translate }} +
    + +
    {{ 'alarm-rule.expression-type.tbel' | translate }} +
    +
    +
    + } +
    +
    +
    {{ 'alarm-rule.condition-settings' | translate }}
    + + alarm-rule.condition-type + + + {{ alarmConditionTypeTranslation.get(alarmConditionType) | translate }} + + + + @if (conditionFormGroup.get('type').value == AlarmConditionType.DURATION) { +
    +
    +
    {{ 'alarm-rule.value' | translate }}
    + + {{ 'alarm-rule.static' | translate }} + {{ 'alarm-rule.dynamic' | translate }} + +
    +
    +
    + +
    +
    + +
    +
    + + + + {{ timeUnitTranslations.get(timeUnit) | translate }} + + + + {{ 'alarm-rule.condition-duration-time-unit-required' | translate }} + + +
    +
    +
    + } @else if (conditionFormGroup.get('type').value == AlarmConditionType.REPEATING) { +
    +
    +
    {{ 'alarm-rule.value' | translate }}
    + + {{ 'alarm-rule.static' | translate }} + {{ 'alarm-rule.dynamic' | translate }} + +
    +
    +
    + +
    +
    + +
    +
    +
    + } +
    +
    +
    +
    + + @if (!readonly) { + + } +
    + + +
    + + + {{ defaultValuePlaceholder | translate }} + @if (conditionFormGroup.get(groupName).get('staticValue').hasError('required')) { + {{ defaultValueRequiredError | translate }} + } @else if (conditionFormGroup.get(groupName).get('staticValue').hasError('min')) { + {{ defaultValueRangeError | translate }} + } @else if (conditionFormGroup.get(groupName).get('staticValue').hasError('max')) { + {{ defaultValueRangeError | translate }} + } @else if (conditionFormGroup.get(groupName).get('staticValue').hasError('pattern')) { + {{ defaultValuePatternError | translate }} + } + +
    +
    + + + + alarm-rule.value-argument + + @for (argument of argumentsList; track argument) { + {{ argument }} + } + + + {{ 'calculated-fields.hint.argument-name-required' | translate }} + + + + +
    + diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts new file mode 100644 index 0000000000..48b290b32c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts @@ -0,0 +1,226 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 } 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, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { TimeUnit, timeUnitTranslationMap } from '@shared/models/time/time.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ScriptLanguage } from "@shared/models/rule-node.models"; +import { + AlarmRuleCondition, + AlarmRuleConditionType, + AlarmRuleConditionTypeTranslationMap, + AlarmRuleExpressionType +} from "@shared/models/alarm-rule.models"; +import { + CalculatedFieldArgument, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights +} from "@shared/models/calculated-field.models"; +import { TbEditorCompleter } from "@shared/models/ace/completion.models"; +import { AceHighlightRules } from "@shared/models/ace/ace.models"; +import { ComplexOperation, complexOperationTranslationMap } from "@shared/models/query/query.models"; + +export interface CfAlarmRuleConditionDialogData { + readonly: boolean; + condition: AlarmRuleCondition; + arguments?: Record; +} + +@Component({ + selector: 'tb-cf-alarm-rule-condition-dialog', + templateUrl: './cf-alarm-rule-condition-dialog.component.html', + providers: [], + styleUrls: ['./cf-alarm-rules-dialog.component.scss'], +}) +export class CfAlarmRuleConditionDialogComponent extends DialogComponent { + + AlarmRuleExpressionType = AlarmRuleExpressionType; + + timeUnits = Object.values(TimeUnit); + timeUnitTranslations = timeUnitTranslationMap; + alarmConditionTypes = Object.values(AlarmRuleConditionType); + AlarmConditionType = AlarmRuleConditionType; + alarmConditionTypeTranslation = AlarmRuleConditionTypeTranslationMap; + readonly = this.data.readonly; + condition = this.data.condition; + + conditionFormGroup = this.fb.group({ + expression: this.fb.group({ + type: [AlarmRuleExpressionType.SIMPLE], + expression: ['', [Validators.required]], + operation: [ComplexOperation.AND], + filters: [null], + }), + type: this.fb.control(AlarmRuleConditionType.SIMPLE, Validators.required), + unit: this.fb.control(TimeUnit.SECONDS, Validators.required), + value: this.fb.group({ + staticValue: this.fb.control(null, [Validators.required, Validators.min(1), Validators.max(2147483647), Validators.pattern('[0-9]*')]), + dynamicValueArgument: this.fb.control('', Validators.required), + }), + count: this.fb.group({ + staticValue: this.fb.control(null, [Validators.required, Validators.min(1), Validators.max(2147483647), Validators.pattern('[0-9]*')]), + dynamicValueArgument: this.fb.control('', Validators.required), + }), + }); + + readonly scriptLanguage = ScriptLanguage; + + defaultValuePlaceholder = ''; + defaultValueRequiredError = ''; + defaultValueRangeError = ''; + defaultValuePatternError = ''; + + durationDynamicModeControl = this.fb.control(false); + repeatingDynamicModeControl = this.fb.control(false); + + ComplexOperation = ComplexOperation; + complexOperationTranslationMap = complexOperationTranslationMap; + + functionArgs: Array; + argumentsEditorCompleter: TbEditorCompleter; + argumentsHighlightRules: AceHighlightRules; + + arguments = this.data.arguments; + argumentsList: Array; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: CfAlarmRuleConditionDialogData, + public dialogRef: MatDialogRef, + private fb: FormBuilder) { + super(store, router, dialogRef); + + this.functionArgs = ['ctx', ...Object.keys(this.data.arguments)]; + this.argumentsEditorCompleter = getCalculatedFieldArgumentsEditorCompleter(this.data.arguments); + this.argumentsHighlightRules = getCalculatedFieldArgumentsHighlights(this.data.arguments); + this.argumentsList = this.arguments ? Object.keys(this.arguments): []; + + this.conditionFormGroup.patchValue({ + expression: { + type: this.condition?.expression?.type ?? AlarmRuleExpressionType.SIMPLE, + expression: this.condition?.expression?.expression ?? null, + filters: this.condition?.expression?.filters ?? [], + operation: this.condition?.expression?.operation ?? ComplexOperation.AND + }, + type: this.condition?.type ?? AlarmRuleConditionType.SIMPLE, + unit: this.condition?.unit ?? TimeUnit.SECONDS, + value: { + staticValue: this.condition?.value?.staticValue, + dynamicValueArgument: this.condition?.value?.dynamicValueArgument, + }, + count: { + staticValue: this.condition?.count?.staticValue ?? null, + dynamicValueArgument: this.condition?.count?.dynamicValueArgument + } + }, {emitEvent: false}); + + this.durationDynamicModeControl.patchValue(!!this.condition?.value?.dynamicValueArgument, {emitEvent: false}); + this.repeatingDynamicModeControl.patchValue(!!this.condition?.count?.dynamicValueArgument, {emitEvent: false}); + + this.conditionFormGroup.get('type').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((type) => { + this.updateValidators(type, true); + }); + + this.conditionFormGroup.get('expression.type').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((type) => { + this.updateExpressionTypeValidator(type); + this.updateValidators(this.conditionFormGroup.get('type').value ?? AlarmRuleConditionType.SIMPLE); + }); + + this.durationDynamicModeControl.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((mode) => { + this.updateStaticValueValidator(AlarmRuleConditionType.DURATION, mode); + }); + this.repeatingDynamicModeControl.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((mode) => { + this.updateStaticValueValidator(AlarmRuleConditionType.REPEATING, mode); + }); + + this.updateValidators(this.conditionFormGroup.get('type').value ?? AlarmRuleConditionType.SIMPLE); + this.updateExpressionTypeValidator(this.condition?.expression?.type ?? 'SIMPLE'); + } + + updateStaticValueValidator(type: AlarmRuleConditionType, dynamicValue: boolean) { + const control = this.conditionFormGroup.get(type === AlarmRuleConditionType.DURATION ? 'value' : 'count'); + if (dynamicValue) { + control.get('staticValue').disable({emitEvent: false}); + control.get('dynamicValueArgument').enable({emitEvent: false}); + } else { + control.get('staticValue').enable({emitEvent: false}); + control.get('dynamicValueArgument').disable({emitEvent: false}); + } + } + + updateExpressionTypeValidator(type: 'SIMPLE' | 'TBEL') { + if (type === 'SIMPLE') { + this.conditionFormGroup.get(`expression.expression`).disable({emitEvent: false}); + this.conditionFormGroup.get(`expression.filters`).enable({emitEvent: false}); + } else { + this.conditionFormGroup.get(`expression.expression`).enable({emitEvent: false}); + this.conditionFormGroup.get(`expression.filters`).disable({emitEvent: false}); + } + } + + private updateValidators(type: AlarmRuleConditionType, emitEvent = false) { + switch (type) { + case AlarmRuleConditionType.DURATION: + this.conditionFormGroup.get('unit').enable({emitEvent: false}); + this.conditionFormGroup.get('value').enable({emitEvent: false}); + this.conditionFormGroup.get('count').disable({emitEvent: false}); + this.updateStaticValueValidator(type, this.durationDynamicModeControl.value); + this.defaultValuePlaceholder = 'alarm-rule.condition-duration-value'; + this.defaultValueRequiredError = 'alarm-rule.condition-duration-value-required'; + this.defaultValueRangeError = 'alarm-rule.condition-duration-value-range'; + this.defaultValuePatternError = 'alarm-rule.condition-duration-value-pattern'; + break; + case AlarmRuleConditionType.REPEATING: + this.conditionFormGroup.get('count').enable({emitEvent: false}); + this.conditionFormGroup.get('value').disable({emitEvent: false}); + this.conditionFormGroup.get('unit').disable({emitEvent: false}); + this.updateStaticValueValidator(type, this.repeatingDynamicModeControl.value); + this.defaultValuePlaceholder = 'alarm-rule.condition-repeating-value'; + this.defaultValueRequiredError = 'alarm-rule.condition-repeating-value-required'; + this.defaultValueRangeError = 'alarm-rule.condition-repeating-value-range'; + this.defaultValuePatternError = 'alarm-rule.condition-repeating-value-pattern'; + break; + case AlarmRuleConditionType.SIMPLE: + this.conditionFormGroup.get('value').disable({emitEvent: false}); + this.conditionFormGroup.get('count').disable({emitEvent: false}); + this.conditionFormGroup.get('unit').disable({emitEvent: false}); + break; + } + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.dialogRef.close(this.conditionFormGroup.value as AlarmRuleCondition); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html new file mode 100644 index 0000000000..e17d8c8261 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html @@ -0,0 +1,52 @@ + +
    +
    +
    {{ 'alarm-rule.condition' | translate }}
    + +
    +
    +
    {{ 'alarm-rule.schedule-title' | translate }}
    + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.scss new file mode 100644 index 0000000000..ac77089857 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.scss @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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: flex; + flex: 1; + + .tb-alarm-rule-condition { + display: flex; + flex: 1; + &-button { + --mat-outlined-button-horizontal-padding: 3px 0px 12px; + display: block; + width: 100%; + } + &-label { + display: block; + text-align: start; + width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts new file mode 100644 index 0000000000..17ed11d892 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts @@ -0,0 +1,281 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { ChangeDetectorRef, Component, forwardRef, Input } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormControl, + Validator, + Validators +} from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { deepClone, isDefinedAndNotNull } from '@core/utils'; +import { TranslateService } from '@ngx-translate/core'; +import { + dayOfWeekTranslations, + getAlarmScheduleRangeText, + utcTimestampToTimeOfDay +} from '@shared/models/device.models'; +import { TimeUnit } from '@shared/models/time/time.models'; +import { + CfAlarmRuleConditionDialogComponent, + CfAlarmRuleConditionDialogData +} from "@home/components/alarm-rules/cf-alarm-rule-condition-dialog.component"; +import { + AlarmRuleCondition, + AlarmRuleConditionType, + AlarmRuleExpressionType, + AlarmRuleSchedule, + AlarmRuleScheduleType +} from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { + AlarmRuleScheduleDialogData, + CfAlarmScheduleDialogComponent +} from "@home/components/alarm-rules/cf-alarm-schedule-dialog.component"; +import { coerceBoolean } from "@shared/decorators/coercion"; + +@Component({ + selector: 'tb-cf-alarm-rule-condition', + templateUrl: './cf-alarm-rule-condition.component.html', + styleUrls: ['./cf-alarm-rule-condition.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CfAlarmRuleConditionComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CfAlarmRuleConditionComponent), + multi: true, + } + ] +}) +export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Validator { + + @Input() + @coerceBoolean() + disabled: boolean; + + @Input() + arguments: Record; + + alarmRuleConditionFormGroup = this.fb.group({ + type: ['SIMPLE'], + expression: [{type: AlarmRuleExpressionType.SIMPLE}, Validators.required], + schedule: [null], + }); + + specText = ''; + + scheduleText = ''; + + private modelValue: AlarmRuleCondition; + + private propagateChange = (v: any) => { }; + + constructor(private dialog: MatDialog, + private fb: FormBuilder, + private cd: ChangeDetectorRef, + private translate: TranslateService) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.alarmRuleConditionFormGroup.disable({emitEvent: false}); + } else { + this.alarmRuleConditionFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: AlarmRuleCondition): void { + this.modelValue = value; + this.updateConditionInfo(); + } + + public conditionSet() { + return this.modelValue && (this.modelValue.expression?.expression || this.modelValue.expression?.filters); + } + + public validate(c: UntypedFormControl) { + return this.conditionSet() ? null : { + alarmRuleCondition: { + valid: false, + }, + }; + } + + public openFilterDialog($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(CfAlarmRuleConditionDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + readonly: this.disabled, + condition: this.disabled ? this.modelValue : deepClone(this.modelValue), + arguments: this.arguments + } + }).afterClosed().subscribe((result) => { + if (result) { + this.modelValue = {...this.modelValue, ...result}; + this.updateModel(); + this.cd.detectChanges(); + } + }); + } + + private updateConditionInfo() { + this.alarmRuleConditionFormGroup.patchValue( + { + type: this.modelValue?.type, + expression: this.modelValue?.expression, + schedule: this.modelValue?.schedule, + }, {emitEvent: false} + ); + this.updateScheduleText(); + this.updateSpecText(); + } + + private updateSpecText() { + this.specText = ''; + if (this.modelValue && this.modelValue.type) { + const type = this.modelValue.type; + switch (type) { + case AlarmRuleConditionType.SIMPLE: + break; + case AlarmRuleConditionType.DURATION: + let duringText = ''; + switch (this.modelValue.unit) { + case TimeUnit.SECONDS: + duringText = this.translate.instant('timewindow.seconds', {seconds: this.modelValue.value.staticValue}); + break; + case TimeUnit.MINUTES: + duringText = this.translate.instant('timewindow.minutes', {minutes: this.modelValue.value.staticValue}); + break; + case TimeUnit.HOURS: + duringText = this.translate.instant('timewindow.hours', {hours: this.modelValue.value.staticValue}); + break; + case TimeUnit.DAYS: + duringText = this.translate.instant('timewindow.days', {days: this.modelValue.value.staticValue}); + break; + } + if (this.modelValue.value.dynamicValueArgument) { + this.specText = this.translate.instant('alarm-rule.condition-during-dynamic', { + attribute: `${this.modelValue.value.dynamicValueArgument}` + }); + } else { + this.specText = this.translate.instant('alarm-rule.condition-during', { + during: duringText + }); + } + break; + case AlarmRuleConditionType.REPEATING: + if (this.modelValue.count.dynamicValueArgument) { + this.specText = this.translate.instant('alarm-rule.condition-repeat-times-dynamic', { + attribute: `${this.modelValue.count.dynamicValueArgument}` + }); + } else { + this.specText = this.translate.instant('alarm-rule.condition-repeat-times', + {count: this.modelValue.count.staticValue}); + } + break; + } + } + } + + private updateModel() { + this.updateConditionInfo(); + this.propagateChange(this.modelValue); + } + + public openScheduleDialog($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(CfAlarmScheduleDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + readonly: this.disabled, + alarmSchedule: this.disabled ? this.modelValue?.schedule : deepClone(this.modelValue?.schedule), + arguments: this.arguments + } + }).afterClosed().subscribe((result) => { + if (result) { + this.modelValue.schedule = result; + this.updateModel(); + this.cd.detectChanges(); + } + }); + } + + private updateScheduleText() { + let schedule = this.modelValue?.schedule; + this.scheduleText = ''; + if (isDefinedAndNotNull(schedule)) { + if (schedule.dynamicValueArgument) { + this.scheduleText = this.translate.instant('alarm-rule.value-argument') + ': ' + schedule?.dynamicValueArgument + } else { + switch (schedule.staticValue.type) { + case AlarmRuleScheduleType.ANY_TIME: + this.scheduleText = this.translate.instant('alarm-rule.schedule.any-time'); + break; + case AlarmRuleScheduleType.SPECIFIC_TIME: + for (const day of schedule.staticValue.daysOfWeek) { + if (this.scheduleText.length) { + this.scheduleText += ', '; + } + this.scheduleText += this.translate.instant(dayOfWeekTranslations[day - 1]); + } + this.scheduleText += ' ' + getAlarmScheduleRangeText(utcTimestampToTimeOfDay(schedule.staticValue.startsOn), + utcTimestampToTimeOfDay(schedule.staticValue.endsOn)) + ''; + break; + case AlarmRuleScheduleType.CUSTOM: + for (const item of schedule.staticValue.items) { + if (item.enabled) { + if (this.scheduleText.length) { + this.scheduleText += ', '; + } + this.scheduleText += this.translate.instant(dayOfWeekTranslations[item.dayOfWeek - 1]); + this.scheduleText += ' ' + getAlarmScheduleRangeText(utcTimestampToTimeOfDay(item.startsOn), + utcTimestampToTimeOfDay(item.endsOn)) + ''; + } + } + break; + } + } + } + if (!this.scheduleText.length) { + this.scheduleText = this.translate.instant('alarm-rule.schedule.any-time'); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html new file mode 100644 index 0000000000..cd6b2c3796 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html @@ -0,0 +1,50 @@ + +
    + + + @if (!disabled || alarmRuleFormGroup.get('alarmDetails').value) { +
    +
    + alarm-rule.alarm-rule-additional-info +
    + + + + +
    + } + @if (!disabled || alarmRuleFormGroup.get('dashboardId').value) { +
    +
    + alarm-rule.alarm-rule-mobile-dashboard +
    + + +
    + } +
    diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.scss new file mode 100644 index 0000000000..21b8a67c24 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.scss @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-alarm-rule-details, .tb-alarm-rule-dashboard { + padding: 4px; + &.title { + opacity: 0.7; + overflow: visible; + } + } + .tb-alarm-rule-details { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + cursor: pointer; + } +} + diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts new file mode 100644 index 0000000000..88ae1dd79d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts @@ -0,0 +1,149 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormControl, + Validator, + Validators +} from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { isDefinedAndNotNull } from '@core/utils'; +import { DashboardId } from '@shared/models/id/dashboard-id'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AlarmRule } from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { + AlarmRuleDetailsDialogComponent, + AlarmRuleDetailsDialogData +} from "@home/components/alarm-rules/alarm-rule-details-dialog.component"; +import { coerceBoolean } from "@shared/decorators/coercion"; + +@Component({ + selector: 'tb-cf-alarm-rule', + templateUrl: './cf-alarm-rule.component.html', + styleUrls: ['./cf-alarm-rule.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CfAlarmRuleComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CfAlarmRuleComponent), + multi: true, + } + ] +}) +export class CfAlarmRuleComponent implements ControlValueAccessor, OnInit, Validator { + + @Input() + @coerceBoolean() + disabled: boolean; + + @Input() + @coerceBoolean() + required: boolean; + + @Input() + arguments: Record; + + private modelValue: AlarmRule; + + alarmRuleFormGroup = this.fb.group({ + condition: this.fb.control({}, Validators.required), + alarmDetails: [null], + dashboardId: [null] + }); + + private propagateChange = (v: any) => { }; + + constructor(private dialog: MatDialog, + private fb: FormBuilder, + private destroyRef: DestroyRef) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.alarmRuleFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.alarmRuleFormGroup.disable({emitEvent: false}); + } else { + this.alarmRuleFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: AlarmRule): void { + this.modelValue = value; + const model = this.modelValue ? { + ...this.modelValue, + dashboardId: this.modelValue.dashboardId?.id + } : null; + this.alarmRuleFormGroup.patchValue(model, {emitEvent: false}); + } + + public openEditDetailsDialog($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AlarmRuleDetailsDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + alarmDetails: this.alarmRuleFormGroup.get('alarmDetails').value, + readonly: this.disabled + } + }).afterClosed().subscribe((alarmDetails) => { + if (isDefinedAndNotNull(alarmDetails)) { + this.alarmRuleFormGroup.patchValue({alarmDetails}); + } + }); + } + + public validate(c: UntypedFormControl) { + return (!this.required && !this.modelValue || this.alarmRuleFormGroup.valid) ? null : { + alarmRule: { + valid: false, + }, + }; + } + + private updateModel() { + const value = this.alarmRuleFormGroup.value; + this.modelValue = {...value, dashboardId: value.dashboardId ? new DashboardId(value.dashboardId) : null} as AlarmRule; + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss new file mode 100644 index 0000000000..14df9a3972 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss @@ -0,0 +1,60 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + form { + width: 900px; + max-width: 100%; + display: grid; + grid-template-rows: min-content minmax(auto, 1fr) min-content; + } + + .tbel-script-lang-chip { + line-height: 20px; + font-size: 14px; + font-weight: 500; + color: white; + border-radius: 100px; + width: 70px; + min-width: 70px; + display: flex; + justify-content: center; + margin-top: 2px; + margin-right: 4px; + } + + .tb-js-func { + .ace_tb { + &.ace_calculated-field { + &-ctx { + color: #C52F00; + } + &-args { + color: #185F2A; + } + &-key { + color: #c24c1a; + } + &-time-window, &-values, &-func, &-value, &-ts, &-latestTs { + color: #7214D0; + } + &-start-ts, &-end-ts { + color: #2CAA00; + } + } + } + } +} + diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.html new file mode 100644 index 0000000000..2d12355f48 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.html @@ -0,0 +1,53 @@ + +
    + +

    {{ (readonly ? 'alarm-rule.schedule-title' : 'alarm-rule.edit-schedule') | translate }}

    + + + {{ 'alarm-rule.static-schedule' | translate }} + {{ 'alarm-rule.dynamic-schedule' | translate }} + + +
    +
    + + +
    +
    + + @if (!readonly) { + + } +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.ts new file mode 100644 index 0000000000..e132dd499e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.ts @@ -0,0 +1,70 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 } 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 } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { AlarmRuleSchedule } from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; + +export interface AlarmRuleScheduleDialogData { + readonly: boolean; + alarmSchedule: AlarmRuleSchedule; + arguments: Record; +} + +@Component({ + selector: 'tb-cf-alarm-schedule-dialog', + templateUrl: './cf-alarm-schedule-dialog.component.html', + providers: [], + styleUrls: ['./cf-alarm-rules-dialog.component.scss'], +}) +export class CfAlarmScheduleDialogComponent extends DialogComponent{ + + readonly = this.data.readonly; + alarmSchedule = this.data.alarmSchedule; + arguments = this.data.arguments; + + alarmScheduleControl = this.fb.control(null); + + dynamicModeControl = this.fb.control(false); + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AlarmRuleScheduleDialogData, + public dialogRef: MatDialogRef, + private fb: FormBuilder) { + super(store, router, dialogRef); + this.alarmScheduleControl.patchValue(this.alarmSchedule, {emitEvent: false}); + this.dynamicModeControl.patchValue(!!this.alarmSchedule?.dynamicValueArgument, {emitEvent: false}); + if (this.readonly) { + this.alarmScheduleControl.disable({emitEvent: false}); + this.dynamicModeControl.disable({emitEvent: false}); + } + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.dialogRef.close(this.alarmScheduleControl.value); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.html new file mode 100644 index 0000000000..7fe09d7850 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.html @@ -0,0 +1,125 @@ + +
    + @if (!dynamicMode) { + + + + @for (scheduleType of alarmScheduleTypes; track scheduleType) { + {{ alarmScheduleTypeTranslate.get(scheduleType) | translate }} + } + + @if (alarmScheduleForm.get('staticValue.type').hasError('required')) { + {{ 'alarm-rule.schedule-type-required' | translate }} + } + + + @if (alarmScheduleForm.get('staticValue.type').value !== alarmScheduleType.ANY_TIME) { +
    +
    + + + @if (alarmScheduleForm.get('staticValue.type').value === alarmScheduleType.SPECIFIC_TIME) { +
    + + + {{ dayOfWeekTranslationsArray[day] | translate }} + + + +
    +
    + + alarm-rule.schedule-time-from + + + + + + alarm-rule.schedule-time-to + + + + +
    +
    +
    +
    +
    +
    + } + @if (alarmScheduleForm.get('staticValue.type').value === alarmScheduleType.CUSTOM) { +
    +
    +
    + + {{ dayOfWeekTranslationsArray[day] | translate }} + +
    + + alarm-rule.schedule-time-from + + + + + + alarm-rule.schedule-time-to + + + + +
    +
    +
    +
    +
    + +
    + } +
    +
    + } + } @else { +
    + + alarm-rule.value-argument + + @for (argument of argumentsList; track argument) { + {{ argument }} + } + + @if (alarmScheduleForm.get('dynamicValueArgument').hasError('required')) { + {{ 'calculated-fields.hint.argument-name-required' | translate }} + } + +
    +
    +
    + } +
    diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.ts new file mode 100644 index 0000000000..b1c7a4da7f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.ts @@ -0,0 +1,322 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, DestroyRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormArray, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { + CustomTimeSchedulerItem, + dayOfWeekTranslations, + getAlarmScheduleRangeText, + timeOfDayToUTCTimestamp, + utcTimestampToTimeOfDay +} from '@shared/models/device.models'; +import { isDefined } from '@core/utils'; +import { getDefaultTimezone } from '@shared/models/time/time.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + AlarmRuleSchedule, + AlarmRuleScheduleType, + AlarmRuleScheduleTypeTranslationMap +} from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { MatChipSelectionChange } from "@angular/material/chips"; +import { coerceBoolean } from "@shared/decorators/coercion"; + +@Component({ + selector: 'tb-cf-alarm-schedule', + templateUrl: './cf-alarm-schedule.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CfAlarmScheduleComponent), + multi: true + }, { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CfAlarmScheduleComponent), + multi: true + }] +}) +export class CfAlarmScheduleComponent implements ControlValueAccessor, Validator, OnInit, OnChanges { + + @Input() + @coerceBoolean() + disabled: boolean; + + @Input() + arguments: Record; + + @Input() + @coerceBoolean() + dynamicMode: boolean; + + alarmScheduleForm = this.fb.group({ + staticValue: this.fb.group({ + type: [AlarmRuleScheduleType.ANY_TIME, Validators.required], + timezone: ['', Validators.required], + daysOfWeek: this.fb.control(null, Validators.required), + startsOn: this.fb.control(0, Validators.required), + endsOn: this.fb.control(0, Validators.required), + items: this.fb.array(Array.from({length: 7}, (value, i) => this.defaultItemsScheduler(i)), this.validateItems), + }), + dynamicValueArgument: ['', Validators.required] + }); + + alarmScheduleTypes = Object.keys(AlarmRuleScheduleType) as Array; + alarmScheduleType = AlarmRuleScheduleType; + alarmScheduleTypeTranslate = AlarmRuleScheduleTypeTranslationMap; + dayOfWeekTranslationsArray = dayOfWeekTranslations; + + argumentsList: Array; + + allDays = Array(7).fill(0).map((x, i) => i); + + private modelValue: AlarmRuleSchedule; + + private defaultItems = Array.from({length: 7}, (value, i) => ({ + enabled: true, + dayOfWeek: i + 1 + })) as CustomTimeSchedulerItem[]; + + private propagateChange = (v: any) => { }; + + constructor(private fb: FormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + this.argumentsList = this.arguments ? Object.keys(this.arguments): []; + this.alarmScheduleForm.get('staticValue.type').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((type) => { + const defaultTimezone = getDefaultTimezone(); + const staticValue = {...this.alarmScheduleForm.get('staticValue').value, type, items: this.defaultItems, timezone: defaultTimezone}; + this.alarmScheduleForm.get('staticValue').patchValue(staticValue, {emitEvent: false}); + this.alarmScheduleForm.get('dynamicValueArgument').patchValue(null, {emitEvent: false}); + this.updateValidators(type); + }); + this.alarmScheduleForm.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + + this.alarmScheduleForm.get('staticValue.items').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((items) => { + items.forEach((item, index) => this.disabledSelectedTime(item.enabled, index, false)) + }); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.dynamicMode) { + const dynamicModeChanges = changes.dynamicMode; + if (!dynamicModeChanges.firstChange && dynamicModeChanges.currentValue !== dynamicModeChanges.previousValue) { + this.updateModeValidators(dynamicModeChanges.currentValue); + this.updateModel(); + } + } + } + + validateItems(control: AbstractControl): ValidationErrors | null { + const items: any[] = control.value; + if (!items || !items.length || !items.find(v => v.enabled === true)) { + return { + dayOfWeeks: true + }; + } + return null; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.alarmScheduleForm.disable({emitEvent: false}); + } else { + this.updateModeValidators(this.dynamicMode); + } + } + + writeValue(value: AlarmRuleSchedule): void { + if (value) { + this.modelValue = value; + if (this.modelValue.dynamicValueArgument) { + this.alarmScheduleForm.get('dynamicValueArgument').patchValue(this.modelValue.dynamicValueArgument, {emitEvent: false}); + } else { + switch (this.modelValue.staticValue.type) { + case AlarmRuleScheduleType.SPECIFIC_TIME: + this.alarmScheduleForm.patchValue({ + staticValue: { + type: this.modelValue.staticValue.type, + timezone: this.modelValue.staticValue.timezone, + daysOfWeek: this.modelValue.staticValue.daysOfWeek, + startsOn: utcTimestampToTimeOfDay(this.modelValue.staticValue.startsOn), + endsOn: utcTimestampToTimeOfDay(this.modelValue.staticValue.endsOn), + }, + }, {emitEvent: false}); + break; + case AlarmRuleScheduleType.CUSTOM: + if (this.modelValue?.dynamicValueArgument) { + this.alarmScheduleForm.patchValue({ + staticValue: { + type: this.modelValue.staticValue.type, + }, + }, {emitEvent: false}); + } else if (this.modelValue.staticValue?.items) { + const alarmDays = []; + this.modelValue.staticValue.items + .sort((a, b) => a.dayOfWeek - b.dayOfWeek) + .forEach((item, index) => { + this.disabledSelectedTime(item.enabled, index); + alarmDays.push({ + enabled: item.enabled, + startsOn: utcTimestampToTimeOfDay(item.startsOn), + endsOn: utcTimestampToTimeOfDay(item.endsOn) + }); + }); + this.alarmScheduleForm.patchValue({ + staticValue: { + type: this.modelValue.staticValue.type, + timezone: this.modelValue.staticValue.timezone, + items: alarmDays, + }, + }, {emitEvent: false}); + } + break; + default: + this.alarmScheduleForm.patchValue(this.modelValue || undefined, {emitEvent: false}); + } + this.updateValidators(this.modelValue.staticValue.type); + } + this.updateModeValidators(this.dynamicMode); + } + } + + validate(control: FormGroup): ValidationErrors | null { + return this.alarmScheduleForm.valid ? null : { + alarmScheduler: { + valid: false + } + }; + } + + private updateModeValidators(mode: boolean) { + if (mode) { + this.alarmScheduleForm.get('staticValue').disable({emitEvent: false}); + this.alarmScheduleForm.get('dynamicValueArgument').enable({emitEvent: false}); + } else { + this.alarmScheduleForm.get('staticValue').enable({emitEvent: false}); + this.alarmScheduleForm.get('dynamicValueArgument').disable({emitEvent: false}); + this.updateValidators(this.alarmScheduleForm.get('staticValue.type').value); + } + } + + private updateValidators(type: AlarmRuleScheduleType){ + switch (type){ + case AlarmRuleScheduleType.ANY_TIME: + this.alarmScheduleForm.get('staticValue.timezone').disable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.daysOfWeek').disable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.startsOn').disable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.endsOn').disable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.items').disable({emitEvent: false}); + break; + case AlarmRuleScheduleType.SPECIFIC_TIME: + this.alarmScheduleForm.get('staticValue.timezone').enable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.daysOfWeek').enable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.startsOn').enable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.endsOn').enable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.items').disable({emitEvent: false}); + break; + case AlarmRuleScheduleType.CUSTOM: + this.alarmScheduleForm.get('staticValue.timezone').enable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.daysOfWeek').disable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.startsOn').disable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.endsOn').disable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.items').enable({emitEvent: false}); + break; + } + } + + private updateModel() { + const value = this.alarmScheduleForm.value as AlarmRuleSchedule; + if (!this.dynamicMode) { + if (isDefined(value.staticValue.startsOn) && value.staticValue.startsOn !== 0) { + value.staticValue.startsOn = timeOfDayToUTCTimestamp(value.staticValue.startsOn); + } + if (isDefined(value.staticValue.endsOn) && value.staticValue.endsOn !== 0) { + value.staticValue.endsOn = timeOfDayToUTCTimestamp(value.staticValue.endsOn); + } + if (isDefined(value.staticValue.items)){ + value.staticValue.items = this.alarmScheduleForm.getRawValue().staticValue.items as CustomTimeSchedulerItem[]; + value.staticValue.items = value.staticValue.items.map((item) => { + return { ...item, startsOn: timeOfDayToUTCTimestamp(item.startsOn), endsOn: timeOfDayToUTCTimestamp(item.endsOn)}; + }); + } + } + this.modelValue = value; + this.propagateChange(this.modelValue); + } + + + private defaultItemsScheduler(index: number): FormGroup { + return this.fb.group({ + enabled: [true], + dayOfWeek: [index + 1], + startsOn: [0, Validators.required], + endsOn: [0, Validators.required] + }); + } + + changeCustomScheduler($event: MatChipSelectionChange, index: number) { + const value = $event.selected; + this.disabledSelectedTime(value, index, true); + } + + private disabledSelectedTime(enable: boolean, index: number, emitEvent = false) { + if (enable) { + this.itemsSchedulerForm.at(index).get('startsOn').enable({emitEvent: false}); + this.itemsSchedulerForm.at(index).get('endsOn').enable({emitEvent}); + } else { + this.itemsSchedulerForm.at(index).get('startsOn').disable({emitEvent: false}); + this.itemsSchedulerForm.at(index).get('endsOn').disable({emitEvent}); + } + } + + getSchedulerRangeText(control: FormGroup | AbstractControl): string { + return getAlarmScheduleRangeText(control.get('startsOn').value, control.get('endsOn').value); + } + + get itemsSchedulerForm(): FormArray { + return this.alarmScheduleForm.get('staticValue.items') as FormArray; + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html new file mode 100644 index 0000000000..820418a6b0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html @@ -0,0 +1,65 @@ + + +
    + @for (createAlarmRuleControl of createAlarmRulesFormArray().controls; track createAlarmRuleControl; let index = $index) { +
    +
    +
    +
    alarm.severity
    + + + + {{ alarmSeverityTranslationMap.get(alarmSeverityEnum[alarmSeverity]) | translate }} + + + +
    + + +
    + +
    + } +
    + + alarm-rule.add-create-alarm-rule-prompt + +
    +
    + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.scss new file mode 100644 index 0000000000..c00cf6af9c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.scss @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + .create-alarm-rule { + border: 1px solid rgba(0, 0, 0, .12); + border-left-width: 4px; + border-radius: 4px; + padding: 8px; + min-width: 0; + } +} + +:host ::ng-deep { + .mat-mdc-form-field.severity { + .mat-mdc-form-field-infix { + width: 160px; + } + } + .button-icon { + color: rgba(0, 0, 0, 0.38); + min-width: 40px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts new file mode 100644 index 0000000000..1ffc52bb34 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts @@ -0,0 +1,187 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, DestroyRef, forwardRef, Input } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormArray, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormControl, + Validator, + Validators +} from '@angular/forms'; +import { AlarmSeverity, alarmSeverityTranslations } from '@shared/models/alarm.models'; +import { AlarmRule } from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { AlarmSeverityNotificationColors } from "@shared/models/notification.models"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { coerceBoolean } from "@shared/decorators/coercion"; + +@Component({ + selector: 'tb-create-cf-alarm-rules', + templateUrl: './create-cf-alarm-rules.component.html', + styleUrls: ['./create-cf-alarm-rules.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CreateCfAlarmRulesComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CreateCfAlarmRulesComponent), + multi: true, + } + ] +}) +export class CreateCfAlarmRulesComponent implements ControlValueAccessor, Validator { + + @Input() + @coerceBoolean() + disabled: boolean; + + @Input() + arguments: Record; + + + alarmSeverities = Object.keys(AlarmSeverity); + alarmSeverityEnum = AlarmSeverity; + alarmSeverityTranslationMap = alarmSeverityTranslations; + + AlarmSeverityNotificationColors = AlarmSeverityNotificationColors; + + createAlarmRulesFormGroup = this.fb.group({ + createAlarmRules: this.fb.array<{severity: AlarmSeverity, alarmRule: AlarmRule}>([]) + }); + + private usedSeverities: AlarmSeverity[] = []; + + private propagateChange = (v: any) => { }; + + constructor(private fb: FormBuilder, + private destroyRef: DestroyRef) { + this.createAlarmRulesFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => this.updateModel()); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + createAlarmRulesFormArray(): UntypedFormArray { + return this.createAlarmRulesFormGroup.get('createAlarmRules') as UntypedFormArray; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.createAlarmRulesFormGroup.disable({emitEvent: false}); + } else { + this.createAlarmRulesFormGroup.enable({emitEvent: false}); + } + } + + writeValue(createAlarmRules: Record): void { + const createAlarmRulesControls: Array = []; + if (createAlarmRules) { + Object.keys(createAlarmRules).forEach((severity) => { + const createAlarmRule = createAlarmRules[severity]; + if (severity === 'empty') { + severity = null; + } + createAlarmRulesControls.push(this.fb.group({ + severity: [severity, Validators.required], + alarmRule: [createAlarmRule, Validators.required] + })); + }); + } + const formArray = this.createAlarmRulesFormGroup.get('createAlarmRules') as FormArray; + formArray.clear(); + createAlarmRulesControls.forEach(c => formArray.push(c)); + if (this.disabled) { + this.createAlarmRulesFormGroup.disable({emitEvent: false}); + } else { + this.createAlarmRulesFormGroup.enable({emitEvent: false}); + } + this.updateUsedSeverities(); + if (!this.disabled && !this.createAlarmRulesFormGroup.valid) { + this.updateModel(); + } + } + + public removeCreateAlarmRule(index: number) { + (this.createAlarmRulesFormGroup.get('createAlarmRules') as FormArray).removeAt(index); + } + + public addCreateAlarmRule() { + const createAlarmRulesArray = this.createAlarmRulesFormGroup.get('createAlarmRules') as FormArray; + createAlarmRulesArray.push(this.fb.group({ + severity: [this.getFirstUnusedSeverity(), Validators.required], + alarmRule: [null, Validators.required] + })); + this.createAlarmRulesFormGroup.updateValueAndValidity(); + if (!this.createAlarmRulesFormGroup.valid) { + this.updateModel(); + } + } + + private getFirstUnusedSeverity(): AlarmSeverity { + for (const severityKey of Object.keys(AlarmSeverity)) { + const severity = AlarmSeverity[severityKey]; + if (this.usedSeverities.indexOf(severity) === -1) { + return severity; + } + } + return null; + } + + public validate(c: UntypedFormControl) { + return (this.createAlarmRulesFormGroup.valid) ? null : { + createAlarmRules: { + valid: false, + }, + }; + } + + public isDisabledSeverity(severity: AlarmSeverity, index: number): boolean { + const usedIndex = this.usedSeverities.indexOf(severity); + return usedIndex > -1 && usedIndex !== index; + } + + private updateUsedSeverities() { + this.usedSeverities = []; + const value = this.createAlarmRulesFormGroup.get('createAlarmRules').value; + value.forEach((rule, index) => { + this.usedSeverities[index] = AlarmSeverity[rule.severity]; + }); + } + + private updateModel() { + const value = this.createAlarmRulesFormGroup.get('createAlarmRules').value; + const createAlarmRules = {} as Record; + value.forEach(v => createAlarmRules[v.severity] = v.alarmRule); + this.updateUsedSeverities(); + this.propagateChange(createAlarmRules); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.html new file mode 100644 index 0000000000..75bce37a80 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.html @@ -0,0 +1,56 @@ + +
    + +

    filter.complex-filter

    + +
    +
    + + filter.operation.operation + + + {{complexOperationTranslations.get(complexOperationEnum[operation]) | translate}} + + + + + +
    +
    + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts new file mode 100644 index 0000000000..941ce326e1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts @@ -0,0 +1,87 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 } 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, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { + ComplexOperation, + complexOperationTranslationMap, + EntityKeyValueType, + FilterPredicateType +} from '@shared/models/query/query.models'; +import { AlarmRuleFilterPredicate, ComplexAlarmRuleFilterPredicate } from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; + +export interface AlarmRuleComplexFilterPredicateDialogData { + complexPredicate: ComplexAlarmRuleFilterPredicate; + isAdd: boolean; + valueType: EntityKeyValueType; + arguments: Record; + argumentInUse: string; +} + +@Component({ + selector: 'tb-alarm-rule-complex-filter-predicate-dialog', + templateUrl: './alarm-rule-complex-filter-predicate-dialog.component.html', + providers: [], + styleUrls: [] +}) + +export class AlarmRuleComplexFilterPredicateDialogComponent extends + DialogComponent { + + complexFilterFormGroup = this.fb.group( + { + operation: [ComplexOperation.AND, [Validators.required]], + predicates: this.fb.control(null, Validators.required) + } + ); + + complexOperations = Object.keys(ComplexOperation); + complexOperationEnum = ComplexOperation; + complexOperationTranslations = complexOperationTranslationMap; + + isAdd: boolean; + + arguments = this.data.arguments; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AlarmRuleComplexFilterPredicateDialogData, + public dialogRef: MatDialogRef, + private fb: FormBuilder) { + super(store, router, dialogRef); + + this.isAdd = this.data.isAdd; + + this.complexFilterFormGroup.patchValue(this.data.complexPredicate, {emitEvent: false}); + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + const predicate = this.complexFilterFormGroup.value as ComplexAlarmRuleFilterPredicate; + predicate.type = FilterPredicateType.COMPLEX; + this.dialogRef.close(predicate); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html new file mode 100644 index 0000000000..abc1dd463d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html @@ -0,0 +1,99 @@ + +
    + +

    {{(data.isAdd ? 'alarm-rule.add-filter' : 'alarm-rule.edit-filter') | translate}}

    + +
    +
    +
    +
    +
    {{ 'alarm-rule.general' | translate }}
    +
    + + alarm-rule.value-argument + + @for (argument of argumentsList; track argument) { + {{ argument }} + } + + @if (filterFormGroup.get('argument').touched && filterFormGroup.get('argument').hasError('required')) { + + warning + + } + + + filter.value-type.value-type + + + + {{ entityKeyValueTypes.get(filterFormGroup.get('valueType').value)?.name | translate }} + + + + {{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).name | translate }} + + + + {{ 'filter.value-type-required' | translate }} + + +
    +
    + +
    +
    +
    {{ 'alarm-rule.filter' | translate }}
    + + {{ complexOperationTranslationMap.get(ComplexOperation.AND) | translate }} + {{ complexOperationTranslationMap.get(ComplexOperation.OR) | translate }} + +
    + + +
    +
    +
    +
    + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.scss new file mode 100644 index 0000000000..0ec7194f02 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.scss @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 ::ng-deep { + .mat-mdc-form-field.tb-value-type { + mat-select-trigger { + .mat-icon { + vertical-align: middle; + margin-right: 8px; + svg { + vertical-align: initial; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts new file mode 100644 index 0000000000..6961524bdd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts @@ -0,0 +1,120 @@ + /// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, DestroyRef, Inject } 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, FormGroup, Validators } from '@angular/forms'; + import { Router } from '@angular/router'; + import { DialogComponent } from '@app/shared/components/dialog.component'; + import { + ComplexOperation, + complexOperationTranslationMap, + EntityKeyValueType, + entityKeyValueTypesMap + } from '@shared/models/query/query.models'; + import { DialogService } from '@core/services/dialog.service'; + import { TranslateService } from '@ngx-translate/core'; + import { AlarmRuleFilter, AlarmRuleFilterPredicate } from "@shared/models/alarm-rule.models"; + import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; + import { FormControlsFrom } from "@shared/models/tenant.model"; + import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + + export interface AlarmRuleFilterDialogData { + filter: AlarmRuleFilter; + isAdd: boolean; + arguments: Record; + usedArguments: Array; +} + +@Component({ + selector: 'tb-alarm-rule-filter-dialog', + templateUrl: './alarm-rule-filter-dialog.component.html', + providers: [], + styleUrls: ['./alarm-rule-filter-dialog.component.scss'] +}) +export class AlarmRuleFilterDialogComponent extends DialogComponent { + + filterFormGroup: FormGroup>; + + entityKeyValueTypesKeys = Object.keys(EntityKeyValueType); + + entityKeyValueTypeEnum = EntityKeyValueType; + + entityKeyValueTypes = entityKeyValueTypesMap; + + complexOperationTranslationMap = complexOperationTranslationMap; + + ComplexOperation = ComplexOperation; + + arguments = this.data.arguments; + argumentsList: Array; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AlarmRuleFilterDialogData, + public dialogRef: MatDialogRef, + private dialogs: DialogService, + private translate: TranslateService, + private fb: FormBuilder, + private destroyRef: DestroyRef) { + super(store, router, dialogRef); + + this.argumentsList = this.arguments ? Object.keys(this.arguments) : []; + + this.filterFormGroup = this.fb.group( + { + argument: [this.data.filter.argument, [Validators.required]], + valueType: [this.data.filter.valueType ?? EntityKeyValueType.STRING, [Validators.required]], + predicates: [this.data.filter.predicates, [Validators.required]], + operation: [this.data.filter.operation ?? ComplexOperation.AND] + } + ); + this.filterFormGroup.get('valueType').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((valueType: EntityKeyValueType) => { + const prevValueType: EntityKeyValueType = this.filterFormGroup.value.valueType; + const predicates: AlarmRuleFilterPredicate[] = this.filterFormGroup.get('predicates').value; + if (prevValueType && prevValueType !== valueType) { + if (predicates && predicates.length) { + this.dialogs.confirm(this.translate.instant('filter.key-value-type-change-title'), + this.translate.instant('filter.key-value-type-change-message')).subscribe( + (result) => { + if (result) { + this.filterFormGroup.get('predicates').setValue([]); + } else { + this.filterFormGroup.get('valueType').setValue(prevValueType, {emitEvent: false}); + } + } + ); + } + } + }); + } + + argumentInUse(argument: string): boolean { + return this.data.usedArguments.includes(argument); + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.dialogRef.close(this.filterFormGroup.value as AlarmRuleFilter); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html new file mode 100644 index 0000000000..3f588a3d70 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html @@ -0,0 +1,79 @@ + +
    +
    + +
    + + +   +
    +
    + +
    + @for (filterControl of filtersFormArray.controls; track filterControl; let index = $index) { +
    +
    + @if ($index) { +
    + {{ complexOperationTranslationMap.get(operation) | translate }} +
    + } +
    +
    +
    +
    {{ filterControl.value?.argument }}
    +
    {{ FilterPredicateTypeTranslationMap.get(filterControl.value?.predicates[0]?.type) | translate }}
    + + +
    +
    + @if (index) { + + } +
    + } + + filter.no-key-filters + +
    +
    + + diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.scss new file mode 100644 index 0000000000..8e74f373f5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.scss @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + .filter-title { + padding: 12px 0; + font-size: 14px; + font-weight: 500; + } + .filter-list { + overflow: auto; + max-height: 300px; + .no-data-found { + height: 50px; + } + + &-divider { + border-top: 1px solid rgba(0, 0, 0, 0.12); + } + } + .filters-operation { + display: flex; + justify-content: center; + margin-top: -14px; + &-container { + background-color: white; + } + &-label { + font-weight: 500; + color: #00695C; + padding: 0 8px; + border-radius: 4px; + border: 1px solid rgba(#00695C, 0.32); + background-color: rgba(#00695C, 0.04); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.ts new file mode 100644 index 0000000000..11e2728659 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.ts @@ -0,0 +1,180 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, DestroyRef, forwardRef, Input } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormArray, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { Observable } from 'rxjs'; +import { + ComplexOperation, + complexOperationTranslationMap, + EntityKeyValueType +} from '@shared/models/query/query.models'; +import { MatDialog } from '@angular/material/dialog'; +import { deepClone } from '@core/utils'; +import { + AlarmRuleFilterDialogComponent, + AlarmRuleFilterDialogData +} from "@home/components/alarm-rules/filter/alarm-rule-filter-dialog.component"; +import { AlarmRuleFilter, FilterPredicateTypeTranslationMap } from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +@Component({ + selector: 'tb-alarm-rule-filter-list', + templateUrl: './alarm-rule-filter-list.component.html', + styleUrls: ['./alarm-rule-filter-list.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterListComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterListComponent), + multi: true + } + ] +}) +export class AlarmRuleFilterListComponent implements ControlValueAccessor, Validator { + + @Input() + arguments: Record; + + @Input() + operation: ComplexOperation = ComplexOperation.AND; + + filterListFormGroup = this.fb.group({ + filters: this.fb.array([]) + }); + + complexOperationTranslationMap = complexOperationTranslationMap; + FilterPredicateTypeTranslationMap = FilterPredicateTypeTranslationMap + + private propagateChange = (v: any) => { }; + + constructor(private fb: FormBuilder, + private dialog: MatDialog, + private destroyRef: DestroyRef) { + this.filterListFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => this.updateModel()); + } + + + get filtersFormArray(): FormArray { + return this.filterListFormGroup.get('filters') as FormArray; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + validate(): ValidationErrors | null { + return this.filterListFormGroup.valid && this.filterListFormGroup.get('filters').value?.length ? null : { + filterList: {valid: false} + }; + } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.filterListFormGroup.disable({emitEvent: false}); + } else { + this.filterListFormGroup.enable({emitEvent: false}); + } + } + + writeValue(filters: Array): void { + const keyFilterControls: Array = []; + if (filters) { + for (const filter of filters) { + keyFilterControls.push(this.fb.control(filter, [Validators.required])); + } + } + this.filtersFormArray.clear(); + keyFilterControls.forEach(c => this.filtersFormArray.push(c)); + } + + public removeFilter(index: number) { + (this.filterListFormGroup.get('filters') as FormArray).removeAt(index); + } + + public addFilter() { + const filtersFormArray = this.filterListFormGroup.get('filters') as FormArray; + this.openFilterDialog(null).subscribe(result => { + if (result) { + filtersFormArray.push(this.fb.control(result, [Validators.required])); + } + }); + } + + public editFilter(index: number) { + const filter: AlarmRuleFilter = + (this.filterListFormGroup.get('filters') as FormArray).at(index).value; + this.openFilterDialog(filter).subscribe(result => { + if (result) { + (this.filterListFormGroup.get('filters') as FormArray).at(index).patchValue(result); + } + }); + } + + private openFilterDialog(filter?: AlarmRuleFilter): Observable { + const isAdd = !filter; + if (isAdd) { + filter = { + argument: null, + valueType: EntityKeyValueType.STRING, + operation: ComplexOperation.AND, + predicates: [] + }; + } + return this.dialog.open(AlarmRuleFilterDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + filter: filter ? deepClone(filter) : null, + isAdd, + arguments: this.arguments, + usedArguments: this.getUsedArguments + } + }).afterClosed(); + } + + get getUsedArguments(): Array { + const filters = this.filterListFormGroup.get('filters').value as Array; + return filters.length ? filters.map((filter: AlarmRuleFilter) => filter.argument) : []; + } + + private updateModel() { + const filters = this.filterListFormGroup.value.filters as Array; + this.propagateChange(filters); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html new file mode 100644 index 0000000000..b7450262f6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html @@ -0,0 +1,86 @@ + +
    +
    +
    +
    +
    + + + +
    +   +
    +
    + +
    + @for (predicateControl of predicatesFormArray.controls; track predicateControl; let index = $index) { +
    + @if (index) { +
    + {{ complexOperationTranslations.get(operation) | translate }} +
    + } +
    +
    + + + +
    +
    +
    + } + + filter.no-filters + +
    +
    +
    + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.scss new file mode 100644 index 0000000000..054d0e5281 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.scss @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + .filter-title { + padding: 12px 8px; + font-size: 14px; + font-weight: 500; + } + .predicate-list { + .no-data-found { + height: 50px; + } + } + + .key-filter-list-divider { + border-top: 1px solid rgba(0, 0, 0, 0.12); + } + .filters-operation { + display: flex; + justify-content: center; + margin-top: -14px; + &-container { + position: absolute; + top: -12px; + left: 10px; + background-color: white; + } + &-label { + font-weight: 500; + color: #00695C; + padding: 0 8px; + border-radius: 4px; + border: 1px solid rgba(#00695C, 0.32); + background-color: rgba(#00695C, 0.04); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.ts new file mode 100644 index 0000000000..ecb52944b2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.ts @@ -0,0 +1,210 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, DestroyRef, forwardRef, Input } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormArray, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { Observable, of } from 'rxjs'; +import { + BooleanOperation, + ComplexOperation, + complexOperationTranslationMap, + EntityKeyValueType, + entityKeyValueTypeToFilterPredicateType, + FilterPredicateType, + NumericOperation, + StringOperation +} from '@shared/models/query/query.models'; +import { MatDialog } from '@angular/material/dialog'; +import { map } from 'rxjs/operators'; +import { + AlarmRuleComplexFilterPredicateDialogComponent, + AlarmRuleComplexFilterPredicateDialogData +} from "@home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component"; +import { + AlarmRuleFilterPredicate, + AlarmRulePredicateInfo, + ComplexAlarmRuleFilterPredicate +} from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +@Component({ + selector: 'tb-alarm-rule-filter-predicate-list', + templateUrl: './alarm-rule-filter-predicate-list.component.html', + styleUrls: ['./alarm-rule-filter-predicate-list.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterPredicateListComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterPredicateListComponent), + multi: true + } + ] +}) +export class AlarmRuleFilterPredicateListComponent implements ControlValueAccessor, Validator { + + @Input() disabled: boolean; + + @Input() valueType: EntityKeyValueType; + + @Input() operation: ComplexOperation = ComplexOperation.AND; + + @Input() arguments: Record; + + @Input() argumentInUse: string; + + filterListFormGroup = this.fb.group({ + predicates: this.fb.array([]) + }); + + valueTypeEnum = EntityKeyValueType; + + complexOperationTranslations = complexOperationTranslationMap; + + private propagateChange= (v: any) => { }; + + constructor(private fb: FormBuilder, + private dialog: MatDialog, + private destroyRef: DestroyRef) { + this.filterListFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => this.updateModel()); + } + + get predicatesFormArray(): FormArray { + return this.filterListFormGroup.get('predicates') as FormArray; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.filterListFormGroup.disable({emitEvent: false}); + } else { + this.filterListFormGroup.enable({emitEvent: false}); + } + } + + validate(control: AbstractControl): ValidationErrors | null { + return this.filterListFormGroup.valid ? null : { + filterList: {valid: false} + }; + } + + writeValue(predicates: Array): void { + const predicateControls: Array = []; + if (predicates) { + for (const predicate of predicates) { + predicateControls.push(this.fb.control(predicate, [Validators.required])); + } + } + this.predicatesFormArray.clear(); + predicateControls.forEach(predicate => this.predicatesFormArray.push(predicate)); + } + + public removePredicate(index: number) { + this.predicatesFormArray.removeAt(index); + } + + public addPredicate(complex: boolean) { + const predicatesFormArray = this.filterListFormGroup.get('predicates') as FormArray; + const predicate = this.createDefaultFilterPredicate(this.valueType, complex); + let observable: Observable; + if (complex) { + observable = this.openComplexFilterDialog(predicate as ComplexAlarmRuleFilterPredicate); + } else { + observable = of(predicate); + } + observable.subscribe((result) => { + if (result) { + predicatesFormArray.push(this.fb.control(result, [Validators.required])); + } + }); + } + + private createDefaultFilterPredicate(valueType: EntityKeyValueType, complex: boolean): AlarmRuleFilterPredicate { + const predicate = { + type: complex ? FilterPredicateType.COMPLEX : entityKeyValueTypeToFilterPredicateType(valueType) + } as AlarmRuleFilterPredicate; + switch (predicate.type) { + case FilterPredicateType.STRING: + predicate.operation = StringOperation.STARTS_WITH; + predicate.value = { + staticValue: '' + }; + predicate.ignoreCase = false; + break; + case FilterPredicateType.NUMERIC: + predicate.operation = NumericOperation.EQUAL; + predicate.value = { + staticValue: valueType === EntityKeyValueType.DATE_TIME ? Date.now() : 0 + }; + break; + case FilterPredicateType.BOOLEAN: + predicate.operation = BooleanOperation.EQUAL; + predicate.value = { + staticValue: false + }; + break; + case FilterPredicateType.COMPLEX: + predicate.operation = ComplexOperation.AND; + predicate.predicates = []; + break; + } + return predicate; + } + + private openComplexFilterDialog(predicate: ComplexAlarmRuleFilterPredicate): Observable { + return this.dialog.open(AlarmRuleComplexFilterPredicateDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + complexPredicate: predicate as ComplexAlarmRuleFilterPredicate, + valueType: this.valueType, + isAdd: true, + arguments: this.arguments, + argumentInUse: this.argumentInUse + } + }).afterClosed().pipe( + map(result => result) + ); + } + + private updateModel() { + this.propagateChange(this.filterListFormGroup.get('predicates').value); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.html new file mode 100644 index 0000000000..10294e851a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.html @@ -0,0 +1,73 @@ + +
    +
    + + + {{'alarm-rule.static' | translate}} + {{'alarm-rule.dynamic' | translate}} + + + @if (!dynamicModeControl.value) { + @switch (valueType) { + @case (valueTypeEnum.STRING) { + + + + } + @case (valueTypeEnum.NUMERIC) { + + + + } + @case (valueTypeEnum.BOOLEAN) { + + {{ (filterPredicateValueFormGroup.get('staticValue').value ? 'value.true' : 'value.false') | translate }} + + } + @case (valueTypeEnum.DATE_TIME) { + + } + } + } @else { + + + @for (argument of argumentsList; track argument) { + {{ argument }} + } + + @if (filterPredicateValueFormGroup.get('dynamicValueArgument').touched && filterPredicateValueFormGroup.get('dynamicValueArgument').hasError('required')) { + + warning + + } + + } +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts new file mode 100644 index 0000000000..036083c826 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts @@ -0,0 +1,155 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + ValidatorFn, + Validators +} from '@angular/forms'; +import { EntityKeyValueType } from '@shared/models/query/query.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { AlarmRuleValue } from "@shared/models/alarm-rule.models"; +import { FormControlsFrom } from "@shared/models/tenant.model"; + +@Component({ + selector: 'tb-alarm-rule-filter-predicate-value', + templateUrl: './alarm-rule-filter-predicate-value.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterPredicateValueComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterPredicateValueComponent), + multi: true + } + ] +}) +export class AlarmRuleFilterPredicateValueComponent implements ControlValueAccessor, Validator, OnInit { + + @Input() + arguments: Record; + + @Input() + valueType: EntityKeyValueType; + + @Input() + argumentInUse: string; + + valueTypeEnum = EntityKeyValueType; + + filterPredicateValueFormGroup: FormGroup>>; + + dynamicModeControl = this.fb.control(false); + + argumentsList: Array; + + private propagateChange= (v: any) => { }; + + constructor(private fb: FormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + this.argumentsList = this.arguments ? Object.keys(this.arguments): []; + let defaultValue: string | number | boolean; + let defaultValueValidators: ValidatorFn[]; + switch (this.valueType) { + case EntityKeyValueType.STRING: + defaultValue = ''; + defaultValueValidators = []; + break; + case EntityKeyValueType.NUMERIC: + defaultValue = 0; + defaultValueValidators = [Validators.required]; + break; + case EntityKeyValueType.BOOLEAN: + defaultValue = false; + defaultValueValidators = []; + break; + case EntityKeyValueType.DATE_TIME: + defaultValue = Date.now(); + defaultValueValidators = [Validators.required]; + break; + } + this.filterPredicateValueFormGroup = this.fb.group({ + staticValue: [defaultValue, defaultValueValidators], + dynamicValueArgument: ['', Validators.required] + }); + this.filterPredicateValueFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + this.dynamicModeControl.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(value => this.updateValueModeValidators(value)); + } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.filterPredicateValueFormGroup.disable({emitEvent: false}); + this.dynamicModeControl.disable({emitEvent: false}); + } else { + this.filterPredicateValueFormGroup.enable({emitEvent: false}); + this.dynamicModeControl.enable({emitEvent: false}); + this.updateValueModeValidators(this.dynamicModeControl.value); + } + } + + private updateValueModeValidators(isDynamicMode: boolean): void { + if (isDynamicMode) { + this.filterPredicateValueFormGroup.get('staticValue').disable({emitEvent: false}); + this.filterPredicateValueFormGroup.get('dynamicValueArgument').enable(); + } else { + this.filterPredicateValueFormGroup.get('dynamicValueArgument').disable({emitEvent: false}); + this.filterPredicateValueFormGroup.get('staticValue').enable(); + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + validate(): ValidationErrors | null { + return this.filterPredicateValueFormGroup.valid ? null : { + filterPredicateValue: {valid: false} + }; + } + + writeValue(predicateValue: AlarmRuleValue): void { + this.filterPredicateValueFormGroup.patchValue(predicateValue, {emitEvent: false}); + this.dynamicModeControl.patchValue(!!predicateValue.dynamicValueArgument?.length, {emitEvent: false}); + } + + private updateModel() { + this.propagateChange(this.filterPredicateValueFormGroup.value); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html new file mode 100644 index 0000000000..d73c222f9d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html @@ -0,0 +1,80 @@ + +
    +
    + @switch (type) { + @case (filterPredicateType.STRING) { +
    + + + + {{stringOperationTranslationMap.get(stringOperation[operation]) | translate}} + + + + + {{ 'alarm-rule.ignore-case' | translate }} + +
    + } + @case (filterPredicateType.NUMERIC) { +
    + + + + {{numericOperationTranslations.get(numericOperationEnum[operation]) | translate}} + + + +
    + } + @case (filterPredicateType.BOOLEAN) { +
    + + + + {{booleanOperationTranslations.get(booleanOperationEnum[operation]) | translate}} + + + +
    + } + @case (filterPredicateType.COMPLEX) { +
    + +
    + } + } + @if (type !== filterPredicateType.COMPLEX) { + + + } +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.ts new file mode 100644 index 0000000000..b508be0c79 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.ts @@ -0,0 +1,162 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { booleanAttribute, Component, DestroyRef, forwardRef, Input } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator +} from '@angular/forms'; +import { + BooleanOperation, + booleanOperationTranslationMap, + EntityKeyValueType, + FilterPredicateType, + NumericOperation, + numericOperationTranslationMap, + StringOperation, + stringOperationTranslationMap +} from '@shared/models/query/query.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AlarmRuleFilterPredicate, ComplexAlarmRuleFilterPredicate } from "@shared/models/alarm-rule.models"; +import { MatDialog } from "@angular/material/dialog"; +import { + AlarmRuleComplexFilterPredicateDialogComponent, + AlarmRuleComplexFilterPredicateDialogData +} from "@home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; + +@Component({ + selector: 'tb-alarm-rule-filter-predicate', + templateUrl: './alarm-rule-filter-predicate.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterPredicateComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterPredicateComponent), + multi: true + } + ] +}) +export class AlarmRuleFilterPredicateComponent implements ControlValueAccessor, Validator { + + @Input({ transform: booleanAttribute }) + disabled: boolean; + + @Input() + valueType: EntityKeyValueType; + + @Input() + arguments: Record; + + @Input() + argumentInUse: string; + + filterPredicateFormGroup = this.fb.group({ + operation: [], + ignoreCase: false, + predicates: [], + value: [] + }); + + type: FilterPredicateType; + + filterPredicateType = FilterPredicateType; + + stringOperations = Object.keys(StringOperation); + stringOperation = StringOperation; + stringOperationTranslationMap = stringOperationTranslationMap; + + numericOperations = Object.keys(NumericOperation); + numericOperationEnum = NumericOperation; + numericOperationTranslations = numericOperationTranslationMap; + + booleanOperations = Object.keys(BooleanOperation); + booleanOperationEnum = BooleanOperation; + booleanOperationTranslations = booleanOperationTranslationMap; + + private propagateChange= (v: any) => { }; + + constructor(private fb: FormBuilder, + private dialog: MatDialog, + private destroyRef: DestroyRef) { + this.filterPredicateFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + validate(): ValidationErrors | null { + return this.filterPredicateFormGroup.valid ? null : { + filterPredicate: {valid: false} + }; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.filterPredicateFormGroup.disable({emitEvent: false}); + } else { + this.filterPredicateFormGroup.enable({emitEvent: false}); + } + } + + writeValue(predicate: AlarmRuleFilterPredicate): void { + this.type = predicate.type; + this.filterPredicateFormGroup.patchValue(predicate, {emitEvent: false}); + } + + private updateModel() { + this.propagateChange({type: this.type, ...this.filterPredicateFormGroup.value}); + } + + public openComplexFilterDialog() { + this.dialog.open(AlarmRuleComplexFilterPredicateDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + complexPredicate: this.filterPredicateFormGroup.value as ComplexAlarmRuleFilterPredicate, + valueType: this.valueType, + isAdd: false, + arguments: this.arguments, + argumentInUse: this.argumentInUse, + } + }).afterClosed().subscribe( + (result) => { + if (result) { + this.filterPredicateFormGroup.patchValue(result); + } + } + ); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.html new file mode 100644 index 0000000000..cb390b6c0b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.html @@ -0,0 +1,23 @@ + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.scss new file mode 100644 index 0000000000..a059bb4564 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.scss @@ -0,0 +1,85 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + text-overflow: ellipsis; + overflow: hidden; + .tb-filter-text { + overflow-y: auto; + text-align: start; + &.required { + color: #f44336; + padding: 0; + } + &.nowrap { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + } +} + +:host ::ng-deep { + .tb-filter-text { + line-height: 1.8em; + span { + display: inline-block; + vertical-align: middle; + line-height: 1.4em; + } + .tb-filter-predicate { + padding-right: 4px; + padding-left: 4px; + } + .tb-filter-entity-key, .tb-filter-value, .tb-filter-dynamic-source { + font-weight: bold; + border: 1px groove rgba(0, 0, 0, .25); + border-radius: 4px; + padding-left: 4px; + padding-right: 4px; + } + .tb-filter-entity-key, .tb-filter-value { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 150px; + } + .tb-filter-dynamic-source { + } + .tb-filter-entity-key { + color: #305680; + } + .tb-filter-value { + color: #ff5722; + } + .tb-filter-simple-operation { + font-size: 0.9em; + } + .tb-filter-complex-operation { + font-weight: 400; + font-style: italic; + } + .tb-filter-dynamic-value { + .tb-filter-dynamic-source, .tb-filter-value { + color: #0c959c; + } + } + .tb-filter-bracket { + .tb-left-bracket, .tb-right-bracket { + font-size: 1.2em; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.ts new file mode 100644 index 0000000000..498b2a0a81 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.ts @@ -0,0 +1,192 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, Input } from '@angular/core'; +import { + booleanOperationTranslationMap, + ComplexOperation, + complexOperationTranslationMap, + EntityKeyValueType, + FilterPredicateType, + numericOperationTranslationMap, + stringOperationTranslationMap +} from '@shared/models/query/query.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { + AlarmRuleExpression, + AlarmRuleExpressionType, + AlarmRuleFilter, + AlarmRuleFilterPredicate, + ComplexAlarmRuleFilterPredicate +} from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { coerceBoolean } from "@shared/decorators/coercion"; + +@Component({ + selector: 'tb-alarm-rule-filter-text', + templateUrl: './alarm-rule-filter-text.component.html', + styleUrls: ['./alarm-rule-filter-text.component.scss'], + providers: [] +}) +export class AlarmRuleFilterTextComponent { + + @Input() + @coerceBoolean() + required = false; + + @Input() + noFilterText = this.translate.instant('filter.no-filter-text'); + + @Input() + addFilterPrompt = this.translate.instant('filter.add-filter-prompt'); + + @Input() + @coerceBoolean() + nowrap = false; + + @Input() + arguments: Record; + + private alarmRuleExpressionValue: AlarmRuleExpression; + get alarmRuleExpression(): AlarmRuleExpression { + return this.alarmRuleExpressionValue; + } + + @Input() + set alarmRuleExpression(value: AlarmRuleExpression) { + if (value !== this.alarmRuleExpressionValue) { + this.alarmRuleExpressionValue = value; + this.updateFilterText(value); + } + }; + + private specTextValue: string; + get specText(): string { + return this.specTextValue; + } + @Input() + set specText(value: string) { + if (value !== this.specTextValue) { + this.specTextValue = value; + this.updateFilterText(this.alarmRuleExpression); + } + } + + isRequired = false; + + public filterText: string; + + constructor(private translate: TranslateService, + private datePipe: DatePipe) { + } + + private updateFilterText(value: AlarmRuleExpression) { + this.isRequired = false; + if (value && (value.expression || value.filters)) { + if (value.type === AlarmRuleExpressionType.SIMPLE) { + this.filterText = this.keyFiltersToText(this.translate, this.datePipe, value.filters, value.operation); + } else { + this.filterText = 'function expression(ctx, ' + (this.arguments ? Object.keys(this.arguments).join(', ') : '' ) + ')'; + } + if (this.specText?.length) { + this.filterText = this.specText + ': ' + this.filterText; + } + } else { + if (this.required) { + this.filterText = this.addFilterPrompt; + this.isRequired = true; + } else { + this.filterText = this.noFilterText; + } + } + } + + private keyFiltersToText(translate: TranslateService, datePipe: DatePipe, keyFilters: Array, operation: ComplexOperation): string { + const filtersText = keyFilters.map(keyFilter => + this.filterPredicateToText(translate, datePipe, keyFilter, keyFilter.predicates)); + let result: string; + if (filtersText.length > 1) { + const operationText = translate.instant(complexOperationTranslationMap.get(operation)); + result = filtersText.join(' ' + operationText + ' '); + } else { + result = filtersText[0]; + } + return result; + } + + private filterPredicateToText(translate: TranslateService, + datePipe: DatePipe, + keyFilter: AlarmRuleFilter, + keyFilterPredicates: AlarmRuleFilterPredicate[], + complexOperation?: ComplexOperation): string { + const key = keyFilter.argument; + const filterOperation: ComplexOperation = complexOperation ? complexOperation : (keyFilter.operation ?? ComplexOperation.AND); + + const predicates = keyFilterPredicates.map((keyFilterPredicate: AlarmRuleFilterPredicate) => { + if (keyFilterPredicate.type === FilterPredicateType.COMPLEX) { + const complexPredicate = keyFilterPredicate as ComplexAlarmRuleFilterPredicate; + const complexOperation = complexPredicate.operation ?? ComplexOperation.AND; + return this.filterPredicateToText(translate, datePipe, keyFilter, complexPredicate.predicates, complexOperation); + } else { + let operation: string; + let value: string; + const val = keyFilterPredicate.value; + const dynamicValue = val?.dynamicValueArgument?.length; + if (dynamicValue) { + value = '' + val?.dynamicValueArgument + ''; + } + switch (keyFilterPredicate.type) { + case FilterPredicateType.STRING: + operation = translate.instant(stringOperationTranslationMap.get(keyFilterPredicate.operation)); + if (keyFilterPredicate.ignoreCase) { + operation += ' ' + translate.instant('filter.ignore-case'); + } + if (!dynamicValue) { + value = `'${keyFilterPredicate.value.staticValue}'`; + } + break; + case FilterPredicateType.NUMERIC: + operation = translate.instant(numericOperationTranslationMap.get(keyFilterPredicate.operation)); + if (!dynamicValue) { + if (keyFilter.valueType === EntityKeyValueType.DATE_TIME) { + value = datePipe.transform(keyFilterPredicate.value.staticValue, 'yyyy-MM-dd HH:mm'); + } else { + value = keyFilterPredicate.value.staticValue + ''; + } + } + break; + case FilterPredicateType.BOOLEAN: + operation = translate.instant(booleanOperationTranslationMap.get(keyFilterPredicate.operation)); + if (!dynamicValue) { + value = translate.instant(keyFilterPredicate.value.staticValue ? 'value.true' : 'value.false'); + } + break; + } + if (!dynamicValue) { + value = `${value}`; + } + return `${key} ${operation} ${value}` + } + }); + if (predicates.length > 1) { + return '(' + predicates.join(` ${translate.instant(complexOperationTranslationMap.get(filterOperation))} `)+ ')'; + } else { + return predicates.toString(); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts new file mode 100644 index 0000000000..5e3a854aa2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts @@ -0,0 +1,65 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + CalculatedFieldDialogComponent +} from '@home/components/calculated-fields/components/dialog/calculated-field-dialog.component'; +import { + CalculatedFieldScriptTestDialogComponent +} from '@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component'; +import { + CalculatedFieldTestArgumentsComponent +} from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component'; +import { + EntityDebugSettingsButtonComponent +} from '@home/components/entity/debug/entity-debug-settings-button.component'; +import { + GeofencingConfigurationModule +} from '@home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module'; +import { + SimpleConfigurationModule +} from '@home/components/calculated-fields/components/simple-configuration/simple-configuration.module'; +import { + PropagationConfigurationModule +} from '@home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module'; +import { + RelatedEntitiesAggregationComponentModule +} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module'; + +@NgModule({ + declarations: [ + CalculatedFieldDialogComponent, + CalculatedFieldScriptTestDialogComponent, + CalculatedFieldTestArgumentsComponent, + ], + imports: [ + CommonModule, + SharedModule, + GeofencingConfigurationModule, + EntityDebugSettingsButtonComponent, + SimpleConfigurationModule, + PropagationConfigurationModule, + RelatedEntitiesAggregationComponentModule, + ], + exports: [ + CalculatedFieldDialogComponent, + CalculatedFieldScriptTestDialogComponent, + ] +}) +export class CalculatedFieldsModule {} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts index 5f4b894448..0ac64b428f 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts @@ -40,10 +40,12 @@ import { ArgumentType, CalculatedField, CalculatedFieldEventArguments, + CalculatedFieldScriptConfiguration, CalculatedFieldType, CalculatedFieldTypeTranslations, getCalculatedFieldArgumentsEditorCompleter, getCalculatedFieldArgumentsHighlights, + PropagationWithExpression, } from '@shared/models/calculated-field.models'; import { CalculatedFieldDebugDialogComponent, @@ -57,6 +59,7 @@ import { ImportExportService } from '@shared/import-export/import-export.service import { isObject } from '@core/utils'; import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; import { DatePipe } from '@angular/common'; +import { UtilsService } from "@core/services/utils.service"; export class CalculatedFieldsTableConfig extends EntityTableConfig { @@ -75,8 +78,10 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig('expression', 'calculated-fields.expression', '300px'); + const expressionColumn = new EntityTableColumn('expression', 'calculated-fields.expression', '250px'); expressionColumn.sortable = false; expressionColumn.cellContentFunction = entity => { const expressionLabel = this.getExpressionLabel(entity); @@ -121,8 +126,9 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig('createdTime', 'common.created-time', this.datePipe, '150px')); - this.columns.push(new EntityTableColumn('name', 'common.name', '33%')); - this.columns.push(new EntityTableColumn('type', 'common.type', '70px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type)))); + this.columns.push(new EntityTableColumn('name', 'common.name', '33%', + entity => this.utilsService.customTranslation(entity.name, entity.name))); + this.columns.push(new EntityTableColumn('type', 'common.type', '170px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type).name), () => ({whiteSpace: 'nowrap' }))); this.columns.push(expressionColumn); this.cellActionDescriptors.push( @@ -156,11 +162,13 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig> { @@ -212,6 +220,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig { - const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { - const type = calculatedField.configuration.arguments[key].refEntityKey.type; - acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) - ? { ...argumentsObj[key], type } - : type === ArgumentType.Rolling ? { values: [], type } : { value: '', type, ts: new Date().getTime() }; - return acc; - }, {}); - return this.dialog.open(CalculatedFieldScriptTestDialogComponent, - { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], - data: { - arguments: resultArguments, - expression: calculatedField.configuration.expression, - argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments), - argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments), - openCalculatedFieldEdit - } - }).afterClosed() - .pipe( - filter(Boolean), - tap(expression => { - if (openCalculatedFieldEdit) { - this.editCalculatedField({ entityId: this.entityId, ...calculatedField, configuration: {...calculatedField.configuration, expression } }, true) + if ( + calculatedField.type === CalculatedFieldType.SCRIPT || + (calculatedField.type === CalculatedFieldType.PROPAGATION && calculatedField.configuration.applyExpressionToResolvedArguments === true) + ) { + const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { + const type = calculatedField.configuration.arguments[key].refEntityKey.type; + acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) + ? {...argumentsObj[key], type} + : type === ArgumentType.Rolling ? {values: [], type} : {value: '', type, ts: new Date().getTime()}; + return acc; + }, {}); + return this.dialog.open(CalculatedFieldScriptTestDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], + data: { + arguments: resultArguments, + expression: (calculatedField.configuration as CalculatedFieldScriptConfiguration | PropagationWithExpression).expression, + argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments), + argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments), + openCalculatedFieldEdit } - }), - ); + }).afterClosed() + .pipe( + filter(Boolean), + tap(expression => { + if (openCalculatedFieldEdit) { + this.editCalculatedField({ + entityId: this.entityId, ...calculatedField, + configuration: {...calculatedField.configuration, expression} as any + }, true) + } + }), + ); + } else { + return of(null); + } } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts index e10c4b301e..ee3d013907 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts @@ -35,6 +35,7 @@ import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; import { ImportExportService } from '@shared/import-export/import-export.service'; import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; import { DatePipe } from '@angular/common'; +import { UtilsService } from "@core/services/utils.service"; @Component({ selector: 'tb-calculated-fields-table', @@ -50,6 +51,7 @@ export class CalculatedFieldsTableComponent { active = input(); entityId = input(); entityName = input(); + ownerId = input(); calculatedFieldsTableConfig: CalculatedFieldsTableConfig; @@ -62,6 +64,7 @@ export class CalculatedFieldsTableComponent { private renderer: Renderer2, private importExportService: ImportExportService, private entityDebugSettingsService: EntityDebugSettingsService, + private utilsService: UtilsService, private destroyRef: DestroyRef) { effect(() => { @@ -76,8 +79,10 @@ export class CalculatedFieldsTableComponent { this.destroyRef, this.renderer, this.entityName(), + this.ownerId(), this.importExportService, this.entityDebugSettingsService, + this.utilsService, ); this.cd.markForCheck(); } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html similarity index 54% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html index 92855d882f..ea922c1aa7 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html @@ -15,75 +15,55 @@ limitations under the License. --> -
    -
    -
    {{ 'calculated-fields.argument-settings' | translate }}
    -
    -
    -
    {{ 'calculated-fields.argument-name' | translate }}
    - - - @if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('required')) { - - warning - - } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('duplicateName')) { - - warning - - } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('pattern')) { - - warning - - } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('maxlength')) { - - warning - - } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('forbiddenName')) { - - warning - - } - +
    +
    {{ 'calculated-fields.argument-settings' | translate }}
    +
    + @if (hint) { +
    + {{ hint | translate }}
    - -
    -
    {{ 'entity.entity-type' | translate }}
    - - - @for (type of argumentEntityTypes; track type) { - {{ ArgumentEntityTypeTranslations.get(type) | translate }} + } +
    + @if (!isOutputKey) { + + } + + @if (!hiddenEntityTypes) { +
    +
    {{ 'entity.entity-type' | translate }}
    + + + @for (type of argumentEntityTypes; track type) { + {{ ArgumentEntityTypeTranslations.get(type) | translate }} + } + + @if (argumentType.touched && argumentType.hasError('required')) { + + warning + } - - -
    + +
    + } @if (ArgumentEntityTypeParamsMap.has(entityType)) {
    {{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}
    + @if (isOutputKey) { + + } @if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Rolling) {
    -
    {{ 'calculated-fields.default-value' | translate }}
    - +
    {{ 'calculated-fields.default-value' | translate }}
    + + @if (argumentFormGroup.get('defaultValue').touched && argumentFormGroup.get('defaultValue').hasError('required')) { + + warning + + }
    } @else { @@ -191,7 +190,7 @@ }
    -
    +
    + + +
    +
    {{ label | translate }}
    + + + @if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('required')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('duplicateName')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('pattern')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('maxlength')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('forbiddenName')) { + + warning + + } + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.scss similarity index 80% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.scss index 773489ee60..f3e13b1f19 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.scss @@ -15,22 +15,9 @@ */ @use '../../../../../../../scss/constants' as constants; -$panel-width: 520px; - :host { - display: flex; - width: $panel-width; - max-width: 100%; - max-height: 100vh; - - .fixed-title-width { - @media #{constants.$mat-xs} { - min-width: 120px; - } - } - .limit-field-row { - @media screen and (max-width: $panel-width) { + @media screen and (max-width: 520px) { display: flex; flex-direction: column; @@ -40,6 +27,10 @@ $panel-width: 520px; } } } + + .tb-primary-fill { + overflow: visible; + } } :host ::ng-deep { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts similarity index 69% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts index 8ccaa4fe49..89db142fac 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts @@ -14,7 +14,16 @@ /// limitations under the License. /// -import { AfterViewInit, ChangeDetectorRef, Component, Input, OnInit, output, ViewChild } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectorRef, + Component, + DestroyRef, + Input, + OnInit, + output, + ViewChild +} from '@angular/core'; import { TbPopoverComponent } from '@shared/components/popover.component'; import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; @@ -25,10 +34,9 @@ import { ArgumentType, ArgumentTypeTranslations, CalculatedFieldArgumentValue, - CalculatedFieldType, getCalculatedFieldCurrentEntityFilter } from '@shared/models/calculated-field.models'; -import { debounceTime, delay, distinctUntilChanged, filter } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'; import { EntityType } from '@shared/models/entity-type.models'; import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { DatasourceType } from '@shared/models/widget.models'; @@ -43,11 +51,12 @@ import { AppState } from '@core/core.state'; import { Store } from '@ngrx/store'; import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component'; import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { TenantId } from '@shared/models/id/tenant-id'; @Component({ selector: 'tb-calculated-field-argument-panel', templateUrl: './calculated-field-argument-panel.component.html', - styleUrls: ['./calculated-field-argument-panel.component.scss'] + styleUrls: ['../common/calculated-field-panel.scss', './calculated-field-argument-panel.component.scss'] }) export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewInit { @@ -56,22 +65,28 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI @Input() entityId: EntityId; @Input() tenantId: string; @Input() entityName: string; - @Input() calculatedFieldType: CalculatedFieldType; + @Input() ownerId: EntityId; + @Input() isScript: boolean; @Input() usedArgumentNames: string[]; + @Input() isOutputKey = false; + @Input() hiddenEntityTypes = false; + @Input() defaultValueRequired = false; + @Input() hint: string; + @Input() predefinedEntityFilter: EntityFilter; + @Input() argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[]; @ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent; argumentsDataApplied = output(); + argumentType = this.fb.control(ArgumentEntityType.Current, Validators.required); + readonly maxDataPointsPerRollingArg = getCurrentAuthState(this.store).maxDataPointsPerRollingArg; readonly defaultLimit = Math.floor(this.maxDataPointsPerRollingArg / 10); argumentFormGroup = this.fb.group({ argumentName: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenArgumentNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], - refEntityId: this.fb.group({ - entityType: [ArgumentEntityType.Current], - id: [''] - }), + refEntityId: [null], refEntityKey: this.fb.group({ type: [ArgumentType.LatestTelemetry, [Validators.required]], key: ['', [Validators.pattern(oneSpaceInsideRegex)]], @@ -86,7 +101,6 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI entityFilter: EntityFilter; entityNameSubject = new BehaviorSubject(null); - readonly argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[]; readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations; readonly ArgumentType = ArgumentType; readonly DataKeyType = DataKeyType; @@ -103,20 +117,16 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI private fb: FormBuilder, private cd: ChangeDetectorRef, private popover: TbPopoverComponent, - private store: Store + private store: Store, + private destroyRef: DestroyRef ) { this.observeEntityFilterChanges(); - this.observeEntityTypeChanges(); + this.observeArgumentTypeChanges(); this.observeEntityKeyChanges(); - this.observeUpdatePosition(); } get entityType(): ArgumentEntityType { - return this.argumentFormGroup.get('refEntityId').get('entityType').value; - } - - get refEntityIdFormGroup(): FormGroup { - return this.argumentFormGroup.get('refEntityId') as FormGroup; + return this.argumentType.value; } get refEntityKeyFormGroup(): FormGroup { @@ -130,14 +140,23 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI } ngOnInit(): void { + this.updatedArgumentType(); this.argumentFormGroup.patchValue(this.argument, {emitEvent: false}); this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId); - this.updateEntityFilter(this.argument.refEntityId?.entityType, true); + this.updateEntityFilter(this.entityType, true); + this.updatedRefEntityIdState(this.entityType, false); this.toggleByEntityKeyType(this.argument.refEntityKey?.type); this.setInitialEntityKeyType(); + this.setInitialEntityType(); + this.setWatchKeyChange(); + + if (this.defaultValueRequired) { + this.argumentFormGroup.get('defaultValue').addValidators(Validators.required); + this.argumentFormGroup.get('defaultValue').updateValueAndValidity({onlySelf: true}); + } this.argumentTypes = Object.values(ArgumentType) - .filter(type => type !== ArgumentType.Rolling || this.calculatedFieldType === CalculatedFieldType.SCRIPT); + .filter(type => type !== ArgumentType.Rolling || this.isScript); } ngAfterViewInit(): void { @@ -147,12 +166,13 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI } saveArgument(): void { - const { refEntityId, ...restConfig } = this.argumentFormGroup.value; - const value = (refEntityId.entityType === ArgumentEntityType.Current ? restConfig : { refEntityId, ...restConfig }) as CalculatedFieldArgumentValue; - if (refEntityId.entityType === ArgumentEntityType.Tenant) { - refEntityId.id = this.tenantId; + const value = this.argumentFormGroup.value as CalculatedFieldArgumentValue; + if (this.entityType === ArgumentEntityType.Owner) { + value.refDynamicSourceConfiguration = {type: ArgumentEntityType.Owner}; + } else if (this.entityType === ArgumentEntityType.Tenant) { + value.refEntityId = new TenantId(this.tenantId) as any; } - if (refEntityId.entityType !== ArgumentEntityType.Current && refEntityId.entityType !== ArgumentEntityType.Tenant) { + if (this.entityType !== ArgumentEntityType.Current && this.entityType !== ArgumentEntityType.Tenant) { value.entityName = this.entityNameSubject.value; } if (value.defaultValue) { @@ -166,6 +186,16 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI this.popover.hide(); } + private updatedArgumentType(): void { + let argumentType = ArgumentEntityType.Current; + if (this.argument.refDynamicSourceConfiguration?.type === ArgumentEntityType.Owner) { + argumentType = ArgumentEntityType.Owner; + } else if (this.argument.refEntityId?.entityType) { + argumentType = this.argument.refEntityId.entityType; + } + this.argumentType.setValue(argumentType, {emitEvent: false}); + } + private toggleByEntityKeyType(type: ArgumentType): void { const isAttribute = type === ArgumentType.Attribute; const isRolling = type === ArgumentType.Rolling; @@ -181,6 +211,12 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI case ArgumentEntityType.Current: entityFilter = this.currentEntityFilter; break; + case ArgumentEntityType.Owner: + entityFilter = { + type: AliasFilterType.singleEntity, + singleEntity: this.ownerId + }; + break; case ArgumentEntityType.Tenant: entityFilter = { type: AliasFilterType.singleEntity, @@ -198,6 +234,8 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI } if (!onInit) { this.argumentFormGroup.get('refEntityKey').get('key').setValue(''); + } else if (this.predefinedEntityFilter) { + entityFilter = this.predefinedEntityFilter; } this.entityFilter = entityFilter; this.cd.markForCheck(); @@ -205,26 +243,21 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI private observeEntityFilterChanges(): void { merge( - this.refEntityIdFormGroup.get('entityType').valueChanges, + this.argumentType.valueChanges, this.refEntityKeyFormGroup.get('type').valueChanges, - this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)), + this.argumentFormGroup.get('refEntityId').valueChanges.pipe(filter(Boolean)), this.refEntityKeyFormGroup.get('scope').valueChanges, ) .pipe(debounceTime(50), takeUntilDestroyed()) .subscribe(() => this.updateEntityFilter(this.entityType)); } - private observeEntityTypeChanges(): void { - this.refEntityIdFormGroup.get('entityType').valueChanges + private observeArgumentTypeChanges(): void { + this.argumentType.valueChanges .pipe(distinctUntilChanged(), takeUntilDestroyed()) .subscribe(type => { - this.argumentFormGroup.get('refEntityId').get('id').setValue(''); - const isEntityWithId = type !== ArgumentEntityType.Tenant && type !== ArgumentEntityType.Current; - this.argumentFormGroup.get('refEntityId') - .get('id')[isEntityWithId ? 'enable' : 'disable'](); - if (!isEntityWithId) { - this.entityNameSubject.next(null); - } + this.argumentFormGroup.get('refEntityId').setValue(null); + this.updatedRefEntityIdState(type); if (!this.enableAttributeScopeSelection) { this.refEntityKeyFormGroup.get('scope').setValue(AttributeScope.SERVER_SCOPE); } @@ -247,29 +280,45 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI } private setInitialEntityKeyType(): void { - if (this.calculatedFieldType === CalculatedFieldType.SIMPLE && this.argument.refEntityKey?.type === ArgumentType.Rolling) { + if (!this.isScript && this.argument.refEntityKey?.type === ArgumentType.Rolling) { const typeControl = this.argumentFormGroup.get('refEntityKey').get('type'); typeControl.setValue(null); typeControl.markAsTouched(); } } + private setInitialEntityType() { + if (!this.argumentEntityTypes.includes(this.entityType)) { + this.argumentType.setValue(null); + this.argumentType.markAsTouched(); + } + } + + private setWatchKeyChange(): void { + if (this.isOutputKey) { + this.refEntityKeyFormGroup.get('key').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((key) => { + if (this.argumentFormGroup.get('argumentName').pristine) { + this.argumentFormGroup.get('argumentName').setValue(key); + } + }); + } + } + private forbiddenArgumentNameValidator(): ValidatorFn { return (control: FormControl) => { const trimmedValue = control.value.trim().toLowerCase(); - const forbiddenArgumentNames = ['ctx', 'e', 'pi']; + const forbiddenArgumentNames = ['ctx', 'e', 'pi', 'propagationCtx']; return forbiddenArgumentNames.includes(trimmedValue) ? { forbiddenName: true } : null; }; } - private observeUpdatePosition(): void { - merge( - this.refEntityIdFormGroup.get('entityType').valueChanges, - this.refEntityKeyFormGroup.get('type').valueChanges, - this.argumentFormGroup.get('timeWindow').valueChanges, - this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)), - ) - .pipe(delay(50), takeUntilDestroyed()) - .subscribe(() => this.popover.updatePosition()); + private updatedRefEntityIdState(type: ArgumentEntityType, emitEvent = true): void { + const isEntityWithId = !!type && ![ArgumentEntityType.Tenant, ArgumentEntityType.Current, ArgumentEntityType.Owner].includes(type); + this.argumentFormGroup.get('refEntityId')[isEntityWithId ? 'enable' : 'disable']({emitEvent}); + if (!isEntityWithId) { + this.entityNameSubject.next(null); + } } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html similarity index 88% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html index 8cf040538c..15d2f4b09b 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html @@ -21,7 +21,7 @@ [matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear> -
    {{ 'common.name' | translate }}
    +
    {{ argumentNameColumn | translate }}
    @@ -29,7 +29,7 @@ @@ -37,13 +37,15 @@ - + {{ 'entity.entity-type' | translate }}
    @if (argument.refEntityId?.entityType === ArgumentEntityType.Tenant) { {{ 'calculated-fields.argument-current-tenant' | translate }} + } @else if (argument.refDynamicSourceConfiguration?.type === ArgumentEntityType.Owner) { + {{ 'calculated-fields.argument-owner' | translate }} } @else if (argument.refEntityId?.id) { {{ entityTypeTranslations.get(argument.refEntityId.entityType).type | translate }} } @else { @@ -88,7 +90,7 @@ -
    +
    - - @if (fieldFormGroup.get('type').value !== CalculatedFieldType.GEOFENCING) { -
    -
    {{ 'calculated-fields.arguments' | translate }}
    - -
    -
    -
    - {{ (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE ? 'calculated-fields.expression' : 'calculated-fields.type.script' ) | translate }} -
    - - -
    -
    - @if (configFormGroup.get('expressionSIMPLE').errors && configFormGroup.get('expressionSIMPLE').touched) { - - @if (configFormGroup.get('expressionSIMPLE').hasError('required')) { - {{ 'calculated-fields.hint.expression-required' | translate }} - } @else if (configFormGroup.get('expressionSIMPLE').hasError('pattern')) { - {{ 'calculated-fields.hint.expression-invalid' | translate }} - } @else if (configFormGroup.get('expressionSIMPLE').hasError('maxLength')) { - {{ 'calculated-fields.hint.expression-max-length' | translate }} - } - - } @else { - {{ 'calculated-fields.hint.expression' | translate }} - } -
    -
    - -
    {{ 'api-usage.tbel' | translate }}
    - -
    -
    - -
    -
    -
    - } @else { -
    -
    - {{ 'calculated-fields.entity-coordinates' | translate }} -
    -
    - - -
    -
    - -
    -
    - {{ 'calculated-fields.geofencing-zone-groups' | translate }} -
    - -
    -
    {{ 'calculated-fields.zone-group-refresh-interval' | translate }}
    -
    - - -
    -
    -
    + @switch (fieldFormGroup.get('type').value) { + @case (CalculatedFieldType.GEOFENCING) { + } -
    -
    {{ 'calculated-fields.output' | translate }}
    -
    - - {{ 'calculated-fields.output-type' | translate }} - - @for (type of outputTypes; track type) { - {{ OutputTypeTranslations.get(type) | translate}} - } - - - @if (outputFormGroup.get('type').value === OutputType.Attribute - && (data.entityId.entityType === EntityType.DEVICE || data.entityId.entityType === EntityType.DEVICE_PROFILE)) { - - {{ 'calculated-fields.attribute-scope' | translate }} - - - {{ 'calculated-fields.server-attributes' | translate }} - - - {{ 'calculated-fields.shared-attributes' | translate }} - - - - } -
    - @if (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) { -
    - - - {{ (outputFormGroup.get('type').value === OutputType.Timeseries - ? 'calculated-fields.timeseries-key' - : 'calculated-fields.attribute-key') - | translate }} - - - @if (outputFormGroup.get('name').errors && outputFormGroup.get('name').touched) { - - @if (outputFormGroup.get('name').hasError('required')) { - {{ 'common.hint.key-required' | translate }} - } @else if (outputFormGroup.get('name').hasError('pattern')) { - {{ 'common.hint.key-pattern' | translate }} - } @else if (outputFormGroup.get('name').hasError('maxlength')) { - {{ 'common.hint.key-max-length' | translate }} - } - - } - - - {{ 'calculated-fields.decimals-by-default' | translate }} - - @if (outputFormGroup.get('decimalsByDefault').errors && outputFormGroup.get('decimalsByDefault').touched) { - {{ 'calculated-fields.hint.decimals-range' | translate }} - } - -
    -
    - -
    - calculated-fields.use-latest-timestamp -
    -
    -
    - } -
    -
    + @case (CalculatedFieldType.PROPAGATION) { + + } + @case (CalculatedFieldType.RELATED_ENTITIES_AGGREGATION) { + + } + @default { + + } + }
    diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss index e192e3ccc0..5e83c1821c 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss @@ -17,6 +17,11 @@ .calculated-field-dialog-container { width: 869px; max-width: 100%; + display: grid; + grid-template-rows: min-content minmax(auto, 1fr) min-content; + --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + --mdc-outlined-text-field-container-shape: 6px; + --mat-form-field-trailing-icon-color: rgba(0, 0, 0, 0.56); } .tbel-script-lang-chip { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts index 3037b3a4ce..17c550dcdf 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts @@ -18,36 +18,25 @@ import { Component, DestroyRef, Inject, ViewEncapsulation } 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, FormGroup, Validators } from '@angular/forms'; +import { FormBuilder, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@shared/components/dialog.component'; import { - ArgumentEntityType, CalculatedField, CalculatedFieldConfiguration, - calculatedFieldDefaultScript, - CalculatedFieldGeofencing, CalculatedFieldTestScriptFn, CalculatedFieldType, - CalculatedFieldTypeTranslations, - getCalculatedFieldArgumentsEditorCompleter, - getCalculatedFieldArgumentsHighlights, - getCalculatedFieldCurrentEntityFilter, - OutputType, - OutputTypeTranslations + CalculatedFieldTypeTranslations } from '@shared/models/calculated-field.models'; -import { digitsRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; -import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { oneSpaceInsideRegex } from '@shared/models/regex.constants'; import { EntityType } from '@shared/models/entity-type.models'; -import { map, startWith, switchMap } from 'rxjs/operators'; +import { pairwise, switchMap } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ScriptLanguage } from '@shared/models/rule-node.models'; import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; import { Observable } from 'rxjs'; import { EntityId } from '@shared/models/id/entity-id'; import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; -import { EntityFilter } from '@shared/models/query/query.models'; -import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { deepTrim } from '@core/utils'; export interface CalculatedFieldDialogData { value?: CalculatedField; @@ -55,6 +44,7 @@ export interface CalculatedFieldDialogData { entityId: EntityId; tenantId: string; entityName?: string; + ownerId: EntityId; additionalDebugActionConfig: AdditionalDebugActionConfig<(calculatedField: CalculatedField) => void>; getTestScriptDialogFn: CalculatedFieldTestScriptFn; isDirty?: boolean; @@ -68,69 +58,22 @@ export interface CalculatedFieldDialogData { }) export class CalculatedFieldDialogComponent extends DialogComponent { - readonly minAllowedScheduledUpdateIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedScheduledUpdateIntervalInSecForCF; - fieldFormGroup = this.fb.group({ name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], type: [CalculatedFieldType.SIMPLE], debugSettings: [], - configuration: this.fb.group({ - entityCoordinates: this.fb.group({ - latitudeKeyName: [null, [Validators.required]], - longitudeKeyName: [null, [Validators.required]], - }), - arguments: this.fb.control({}), - zoneGroups: this.fb.control({}), - scheduledUpdateInterval: [this.minAllowedScheduledUpdateIntervalInSecForCF], - expressionSIMPLE: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], - expressionSCRIPT: [calculatedFieldDefaultScript], - output: this.fb.group({ - name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], - scope: [{ value: AttributeScope.SERVER_SCOPE, disabled: true }], - type: [OutputType.Timeseries], - decimalsByDefault: [null as number, [Validators.min(0), Validators.max(15), Validators.pattern(digitsRegex)]], - }), - useLatestTs: [false] - }), + configuration: this.fb.control({} as CalculatedFieldConfiguration), }); - functionArgs$ = this.configFormGroup.get('arguments').valueChanges - .pipe( - startWith(this.data.value?.configuration?.arguments ?? {}), - map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)]) - ); - - argumentsEditorCompleter$ = this.configFormGroup.get('arguments').valueChanges - .pipe( - startWith(this.data.value?.configuration?.arguments ?? {}), - map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj)) - ); - - argumentsHighlightRules$ = this.configFormGroup.get('arguments').valueChanges - .pipe( - startWith(this.data.value?.configuration?.arguments ?? {}), - map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj)) - ); - additionalDebugActionConfig = this.data.value?.id ? { ...this.data.additionalDebugActionConfig, action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }), } : null; - currentEntityFilter: EntityFilter; - - isRelatedEntity: boolean; - - readonly OutputTypeTranslations = OutputTypeTranslations; - readonly OutputType = OutputType; - readonly AttributeScope = AttributeScope; readonly EntityType = EntityType; readonly CalculatedFieldType = CalculatedFieldType; - readonly ScriptLanguage = ScriptLanguage; - readonly fieldTypes = Object.values(CalculatedFieldType) as CalculatedFieldType[]; - readonly outputTypes = Object.values(OutputType) as OutputType[]; + readonly fieldTypes = Object.values(CalculatedFieldType).filter(type => type !== CalculatedFieldType.ALARM) as CalculatedFieldType[]; readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations; - readonly DataKeyType = DataKeyType; constructor(protected store: Store, protected router: Router, @@ -141,48 +84,12 @@ export class CalculatedFieldDialogComponent extends DialogComponent { const calculatedFieldId = this.data.value?.id?.id; - let testScriptDialogResult$: Observable; - if (calculatedFieldId) { - testScriptDialogResult$ = this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId) + return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId, {ignoreLoading: true}) .pipe( switchMap(event => { const args = event?.arguments ? JSON.parse(event.arguments) : null; @@ -210,97 +115,14 @@ export class CalculatedFieldDialogComponent extends DialogComponent { - this.configFormGroup.get('expressionSCRIPT').setValue(expression); - this.configFormGroup.get('expressionSCRIPT').markAsDirty(); - }); + return this.data.getTestScriptDialogFn(this.fromGroupValue, null, false); } private applyDialogData(): void { - const { configuration = {}, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {}; - const { expression, ...restConfig } = configuration as CalculatedFieldConfiguration; - const updatedConfig = { ...restConfig , ['expression'+type]: expression }; - this.fieldFormGroup.patchValue({ configuration: updatedConfig, type, debugSettings, ...value }, {emitEvent: false}); - } - - private observeTypeChanges(): void { - this.toggleKeyByCalculatedFieldType(this.fieldFormGroup.get('type').value); - this.toggleScopeByOutputType(this.outputFormGroup.get('type').value); - - this.outputFormGroup.get('type').valueChanges - .pipe(takeUntilDestroyed()) - .subscribe(type => this.toggleScopeByOutputType(type)); - this.fieldFormGroup.get('type').valueChanges - .pipe(takeUntilDestroyed()) - .subscribe(type => this.toggleKeyByCalculatedFieldType(type)); - } - - private observeZoneChanges(): void { - this.configFormGroup.get('zoneGroups').valueChanges - .pipe(takeUntilDestroyed()) - .subscribe((zoneGroups: CalculatedFieldGeofencing) => - this.checkRelatedEntity(zoneGroups) - ); - this.checkRelatedEntity(this.configFormGroup.get('zoneGroups').value); - } - - private checkRelatedEntity(zoneGroups: CalculatedFieldGeofencing) { - this.isRelatedEntity = Object.values(zoneGroups).some(zone => zone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery); - } - - private toggleScopeByOutputType(type: OutputType): void { - if (type === OutputType.Attribute) { - this.outputFormGroup.get('scope').enable({emitEvent: false}); - } else { - this.outputFormGroup.get('scope').disable({emitEvent: false}); - } - if (this.fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) { - if (type === OutputType.Attribute) { - this.configFormGroup.get('useLatestTs').disable({emitEvent: false}); - } else { - this.configFormGroup.get('useLatestTs').enable({emitEvent: false}); - } - } else { - this.configFormGroup.get('useLatestTs').disable({emitEvent: false}); - } - } - - private toggleKeyByCalculatedFieldType(type: CalculatedFieldType): void { - if (type === CalculatedFieldType.GEOFENCING) { - this.configFormGroup.get('entityCoordinates').enable({emitEvent: false}); - this.configFormGroup.get('zoneGroups').enable({emitEvent: false}); - this.configFormGroup.get('scheduledUpdateInterval').enable({emitEvent: false}); - - this.outputFormGroup.get('name').disable({emitEvent: false}); - this.configFormGroup.get('useLatestTs').disable({emitEvent: false}); - this.configFormGroup.get('expressionSIMPLE').disable({emitEvent: false}); - this.configFormGroup.get('expressionSCRIPT').disable({emitEvent: false}); - this.configFormGroup.get('arguments').disable({emitEvent: false}); - } else { - this.configFormGroup.get('entityCoordinates').disable({emitEvent: false}); - this.configFormGroup.get('zoneGroups').disable({emitEvent: false}); - this.configFormGroup.get('scheduledUpdateInterval').disable({emitEvent: false}); - - if (type === CalculatedFieldType.SIMPLE) { - this.outputFormGroup.get('name').enable({emitEvent: false}); - this.configFormGroup.get('expressionSIMPLE').enable({emitEvent: false}); - this.configFormGroup.get('expressionSCRIPT').disable({emitEvent: false}); - if (this.outputFormGroup.get('type').value === OutputType.Attribute) { - this.configFormGroup.get('useLatestTs').disable({emitEvent: false}); - } else { - this.configFormGroup.get('useLatestTs').enable({emitEvent: false}); - } - } else { - this.outputFormGroup.get('name').disable({emitEvent: false}); - this.configFormGroup.get('useLatestTs').disable({emitEvent: false}); - this.configFormGroup.get('expressionSIMPLE').disable({emitEvent: false}); - this.configFormGroup.get('expressionSCRIPT').enable({emitEvent: false}); - } - } + const { configuration = {} as CalculatedFieldConfiguration, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {}; + this.fieldFormGroup.patchValue({ configuration, type, debugSettings, ...value }, {emitEvent: false}); + setTimeout(() => this.fieldFormGroup.get('type').updateValueAndValidity({onlySelf: true})); } private observeIsLoading(): void { @@ -309,12 +131,22 @@ export class CalculatedFieldDialogComponent extends DialogComponent { + if (![CalculatedFieldType.SIMPLE, CalculatedFieldType.SCRIPT].includes(prevType) || + ![CalculatedFieldType.SIMPLE, CalculatedFieldType.SCRIPT].includes(nextType)) { + this.fieldFormGroup.get('configuration').setValue(({} as CalculatedFieldConfiguration), {emitEvent: false}); + } + }); + } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html new file mode 100644 index 0000000000..1f5444f8b3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html @@ -0,0 +1,268 @@ + +
    +
    {{ 'calculated-fields.geofencing-zone-groups-settings' | translate }}
    +
    +
    +
    {{ 'calculated-fields.name' | translate }}
    + + + @if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('required')) { + + warning + + } @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('duplicateName')) { + + warning + + } @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('pattern')) { + + warning + + } @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('maxlength')) { + + warning + + } @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('forbiddenName')) { + + warning + + } + +
    + +
    +
    {{ 'entity.entity-type' | translate }}
    + + + @for (type of argumentEntityTypes; track type) { + {{ ArgumentEntityTypeTranslations.get(type) | translate }} + } + + +
    + @if (ArgumentEntityTypeParamsMap.has(entityType)) { +
    +
    {{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}
    + +
    + } +
    + +
    + + {{ 'calculated-fields.entity-zone-relationship' | translate }} +
    +
    +
    +
    calculated-fields.level
    +
    calculated-fields.direction-level
    +
    calculated-fields.relation-type
    +
    +
    + @if (levelsFormArray()?.controls?.length) { +
    + @for (keyControl of levelsFormArray().controls; track trackByKey; ) { +
    +
    + +
    +
    +
    {{ $index + 1 }}
    + + + @for (direction of GeofencingDirectionList; track direction) { + {{ GeofencingDirectionLevelTranslations.get(direction) | translate }} + } + + + + +
    +
    + +
    +
    + } +
    + } @else { + {{ 'calculated-fields.no-level' | translate }} + } + @if (levelsFormArray().errors) { + + } +
    +
    + @if (maxRelationLevelPerCfArgument && levelsFormArray().length >= maxRelationLevelPerCfArgument) { +
    + warning + {{ 'calculated-fields.max-allowed-levels-error' | translate }} +
    + } @else { + + } +
    +
    +
    +
    + + @if (entityFilter.singleEntity?.id || entityType === ArgumentEntityType.RelationQuery || entityType === ArgumentEntityType.Current) { +
    +
    + {{ 'calculated-fields.perimeter-attribute-key' | translate }} +
    + @if (entityType === ArgumentEntityType.RelationQuery) { + + + @if (geofencingFormGroup.get('perimeterKeyName').touched && geofencingFormGroup.get('perimeterKeyName').hasError('required')) { + + warning + + } @else if (geofencingFormGroup.get('perimeterKeyName').touched && geofencingFormGroup.get('perimeterKeyName').hasError('pattern')) { + + warning + + } + + } @else { + + } +
    + } +
    +
    {{ 'calculated-fields.report-strategy' | translate }}
    + + + @for (strategy of GeofencingReportStrategyList; track strategy) { + {{ GeofencingReportStrategyTranslations.get(strategy) | translate }} + } + + +
    +
    +
    + +
    + {{ 'calculated-fields.create-relation-with-matched-zones' | translate }} +
    +
    +
    +
    {{ 'calculated-fields.direction' | translate }}
    + + + @for (direction of GeofencingDirectionList; track direction) { + {{ GeofencingDirectionTranslations.get(direction) | translate }} + } + + +
    +
    +
    {{ 'calculated-fields.relation-type' | translate }}
    + + +
    +
    +
    +
    + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.scss similarity index 58% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.scss rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.scss index bedaf2eeb0..4963e67a2b 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.scss @@ -15,22 +15,43 @@ */ @import '../../../../../../../scss/constants'; -$panel-width: 520px; - :host { - display: flex; - width: $panel-width; - max-width: 100%; - max-height: 80vh; - - .fixed-title-width { - @media #{$mat-xs} { - min-width: 120px; + .level-text { + display: flex; + justify-content: center; + width: 25px; + color: rgba(0, 0, 0, 0.54); + } + + .tb-form-table { + .tb-form-row { + gap: 12px; + } + + .tb-form-table-body { + gap: unset; + } + + .tb-form-table-header { + padding: 0; + } + + .tb-form-table-header-cell { + &.tb-actions-header { + width: 40px; + min-width: 40px; + } + } + } + + .max-args-warning { + .mat-icon { + color: #FAA405; } } .limit-field-row { - @media screen and (max-width: $panel-width) { + @media screen and (max-width: 520px) { display: flex; flex-direction: column; @@ -43,9 +64,9 @@ $panel-width: 520px; } :host ::ng-deep { - .time-interval-field { - .advanced-input { - flex-direction: column; + tb-entity-autocomplete { + .mat-mdc-form-field-has-icon-suffix .mat-mdc-text-field-wrapper { + padding-right: 0 !important; } } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts similarity index 79% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.ts rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts index 72ea58919f..6698de93b9 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts @@ -16,7 +16,15 @@ import { AfterViewInit, ChangeDetectorRef, Component, Input, OnInit, output, ViewChild } from '@angular/core'; import { TbPopoverComponent } from '@shared/components/popover.component'; -import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { + AbstractControl, + FormBuilder, + FormControl, + FormGroup, + UntypedFormArray, + ValidatorFn, + Validators +} from '@angular/forms'; import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; import { ArgumentEntityType, @@ -24,15 +32,15 @@ import { ArgumentEntityTypeTranslations, CalculatedFieldGeofencing, CalculatedFieldGeofencingValue, - CalculatedFieldType, + GeofencingDirectionLevelTranslations, GeofencingDirectionTranslations, GeofencingReportStrategy, GeofencingReportStrategyTranslations, getCalculatedFieldCurrentEntityFilter } from '@shared/models/calculated-field.models'; -import { debounceTime, delay, distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'; import { EntityType } from '@shared/models/entity-type.models'; -import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { EntityId } from '@shared/models/id/entity-id'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { EntityFilter } from '@shared/models/query/query.models'; @@ -44,11 +52,12 @@ import { Store } from '@ngrx/store'; import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { EntitySearchDirection } from '@shared/models/relation.models'; +import { CdkDragDrop } from "@angular/cdk/drag-drop"; @Component({ selector: 'tb-calculated-field-geofencing-zone-groups-panel', templateUrl: './calculated-field-geofencing-zone-groups-panel.component.html', - styleUrls: ['./calculated-field-geofencing-zone-groups-panel.component.scss'] + styleUrls: ['../common/calculated-field-panel.scss', './calculated-field-geofencing-zone-groups-panel.component.scss'] }) export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit, AfterViewInit { @@ -57,7 +66,7 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit @Input() entityId: EntityId; @Input() tenantId: string; @Input() entityName: string; - @Input() calculatedFieldType: CalculatedFieldType; + @Input() ownerId: EntityId; @Input() usedNames: string[]; @ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent; @@ -73,12 +82,9 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit id: [''] }), refDynamicSourceConfiguration: this.fb.group({ - direction: [EntitySearchDirection.TO], - relationType: ['', [Validators.required]], - maxLevel: [1, [Validators.required, Validators.min(1), Validators.max(this.maxRelationLevelPerCfArgument)]], - fetchLastLevelOnly: [false], + levels: this.fb.array([], [this.levelsRequired()]) }), - perimeterKeyName: ['', [Validators.pattern(oneSpaceInsideRegex)]], + perimeterKeyName: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex)]], reportStrategy: [GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS], createRelationsWithMatchedZones: [false], direction: [EntitySearchDirection.TO], @@ -97,6 +103,8 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit readonly GeofencingReportStrategyTranslations = GeofencingReportStrategyTranslations; readonly GeofencingDirectionList = Object.values(EntitySearchDirection) as Array; readonly GeofencingDirectionTranslations = GeofencingDirectionTranslations; + readonly GeofencingDirectionLevelTranslations = GeofencingDirectionLevelTranslations; + readonly AttributeScope = AttributeScope; private currentEntityFilter: EntityFilter; @@ -107,10 +115,8 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit private store: Store ) { - this.observeMaxLevelChanges(); this.observeEntityFilterChanges(); this.observeEntityTypeChanges(); - this.observeUpdatePosition(); this.observeCreateRelationZonesChanges(); } @@ -131,7 +137,16 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit if (this.zone.refDynamicSourceConfiguration?.type) { this.refEntityIdFormGroup.get('entityType').setValue(this.zone.refDynamicSourceConfiguration.type, {emitEvent: false}); } - this.validateFetchLastLevelOnly(this.zone?.refDynamicSourceConfiguration?.maxLevel); + if (this.zone?.refDynamicSourceConfiguration?.levels?.length > 0) { + this.zone.refDynamicSourceConfiguration.levels.forEach(level => { + this.levelsFormArray().push(this.fb.group({ + direction: [level.direction], + relationType: [level.relationType, [Validators.required]] + })); + }) + } else { + this.addKey(); + } this.validateDirectionAndRelationType(this.zone?.createRelationsWithMatchedZones); this.validateRefDynamicSourceConfiguration(this.zone?.refEntityId?.entityType || this.zone?.refDynamicSourceConfiguration?.type); @@ -144,26 +159,12 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit return of(['Contains', 'Manages']).pipe(map(name => name?.filter(option => option.toLowerCase().includes(search)))); } - private observeMaxLevelChanges(): void { - this.refDynamicSourceFormGroup.get('maxLevel').valueChanges - .pipe(takeUntilDestroyed()) - .subscribe(value => this.validateFetchLastLevelOnly(value)); - } - private observeCreateRelationZonesChanges(): void { this.geofencingFormGroup.get('createRelationsWithMatchedZones').valueChanges .pipe(takeUntilDestroyed()) .subscribe(value => this.validateDirectionAndRelationType(value)); } - private validateFetchLastLevelOnly(maxLevel = 1): void { - if (maxLevel > 1) { - this.refDynamicSourceFormGroup.get('fetchLastLevelOnly').enable({emitEvent: false}); - } else { - this.refDynamicSourceFormGroup.get('fetchLastLevelOnly').disable({emitEvent: false}); - } - } - private validateDirectionAndRelationType(createRelation = false): void { if (createRelation) { this.geofencingFormGroup.get('direction').enable({emitEvent: false}); @@ -195,6 +196,10 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit case ArgumentEntityType.Current: delete value.refEntityId; break; + case ArgumentEntityType.Owner: + delete value.refEntityId; + value.refDynamicSourceConfiguration = {type: ArgumentEntityType.Owner}; + break; case ArgumentEntityType.RelationQuery: delete value.refEntityId; value.refDynamicSourceConfiguration.type = ArgumentEntityType.RelationQuery; @@ -219,6 +224,12 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit case ArgumentEntityType.RelationQuery: entityFilter = this.currentEntityFilter; break; + case ArgumentEntityType.Owner: + entityFilter = { + type: AliasFilterType.singleEntity, + singleEntity: this.ownerId + }; + break; case ArgumentEntityType.Tenant: entityFilter = { type: AliasFilterType.singleEntity, @@ -241,17 +252,19 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit private observeEntityFilterChanges(): void { merge( this.refEntityIdFormGroup.get('entityType').valueChanges, - this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)), + this.refEntityIdFormGroup.get('id').valueChanges, ) .pipe(debounceTime(50), takeUntilDestroyed()) .subscribe(() => this.updateEntityFilter(this.entityType)); + + this.refEntityIdFormGroup.get('id').valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed()).subscribe(() => this.geofencingFormGroup.get('perimeterKeyName').reset('')); } private observeEntityTypeChanges(): void { this.refEntityIdFormGroup.get('entityType').valueChanges .pipe(distinctUntilChanged(), takeUntilDestroyed()) .subscribe(type => { - this.geofencingFormGroup.get('refEntityId').get('id').setValue(''); + this.geofencingFormGroup.get('refEntityId').get('id').setValue(null); const isEntityWithId = type !== ArgumentEntityType.Tenant && type !== ArgumentEntityType.Current && type !== ArgumentEntityType.RelationQuery; this.geofencingFormGroup.get('refEntityId') .get('id')[isEntityWithId ? 'enable' : 'disable'](); @@ -271,6 +284,12 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit }; } + private levelsRequired(): ValidatorFn { + return (control: FormControl) => { + return control.value.length ? null : { levelsRequired: true }; + }; + } + private forbiddenNameValidator(): ValidatorFn { return (control: FormControl) => { const trimmedValue = control.value.trim().toLowerCase(); @@ -279,13 +298,33 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit }; } - private observeUpdatePosition(): void { - merge( - this.refEntityIdFormGroup.get('entityType').valueChanges, - this.geofencingFormGroup.get('createRelationsWithMatchedZones').valueChanges - ) - .pipe(delay(50), takeUntilDestroyed()) - .subscribe(() => this.popover.updatePosition()); + levelsFormArray(): UntypedFormArray { + return this.refDynamicSourceFormGroup.get('levels') as UntypedFormArray; + } + + trackByKey(_index: number, keyControl: AbstractControl): any { + return keyControl; } + removeKey(index: number) { + this.levelsFormArray().removeAt(index); + } + + addKey() { + this.levelsFormArray().push(this.fb.group({ + direction: [EntitySearchDirection.TO], + relationType: ['', [Validators.required]] + })); + } + + keyDrop(event: CdkDragDrop) { + const keysArray = this.levelsFormArray(); + const key = keysArray.at(event.previousIndex); + keysArray.removeAt(event.previousIndex); + keysArray.insert(event.currentIndex, key); + } + + get dragEnabled(): boolean { + return this.levelsFormArray().controls.length > 1; + } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.html similarity index 96% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.html rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.html index f8bd9371f0..dcfd37796d 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.html @@ -35,13 +35,15 @@ - + {{ 'entity.entity-type' | translate }}
    @if (geofenceZone.refEntityId?.entityType === ArgumentEntityType.Tenant) { {{ 'calculated-fields.argument-current-tenant' | translate }} + } @else if (geofenceZone.refDynamicSourceConfiguration?.type === ArgumentEntityType.Owner) { + {{ 'calculated-fields.argument-owner' | translate }} } @else if (geofenceZone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery) { {{ 'calculated-fields.argument-relation-query' | translate }} } @else if (geofenceZone.refEntityId?.id) { @@ -91,7 +93,7 @@ -
    +
    - -
    -
    diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html new file mode 100644 index 0000000000..cc8cf7dc4f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html @@ -0,0 +1,100 @@ + +
    +
    +
    + {{ 'calculated-fields.propagation-path-related-entities' | translate }} +
    +
    + + {{ 'calculated-fields.direction' | translate }} + + @for (direction of Directions; track direction) { + {{ PropagationDirectionTranslations.get(direction) | translate }} + } + + + + +
    +
    +
    +
    +
    + {{ 'calculated-fields.data-propagate' | translate }} +
    + + {{ 'calculated-fields.propagate-type.arguments-only' | translate }} + {{ 'calculated-fields.propagate-type.expression-result' | translate }} + +
    + +
    +
    +
    + {{ 'calculated-fields.expression' | translate }} +
    +
    + +
    {{ 'api-usage.tbel' | translate }} +
    + +
    +
    + +
    +
    +
    + + +
    diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts new file mode 100644 index 0000000000..4485ef2032 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts @@ -0,0 +1,178 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, forwardRef, Input } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { EntityId } from '@shared/models/id/entity-id'; +import { Observable, of } from 'rxjs'; +import { + calculatedFieldDefaultScript, + CalculatedFieldOutput, + CalculatedFieldPropagationConfiguration, + CalculatedFieldType, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights, + notEmptyObjectValidator, + OutputType, + PropagationDirectionTranslations, + PropagationWithExpression +} from '@shared/models/calculated-field.models'; +import { map } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ScriptLanguage } from '@app/shared/models/rule-node.models'; +import { EntitySearchDirection } from '@shared/models/relation.models'; + +@Component({ + selector: 'tb-propagation-configuration', + templateUrl: './propagation-configuration.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PropagationConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => PropagationConfigurationComponent), + multi: true + } + ], +}) +export class PropagationConfigurationComponent implements ControlValueAccessor, Validator { + + @Input({required: true}) + entityId: EntityId; + + @Input({required: true}) + tenantId: string; + + @Input({required: true}) + entityName: string; + + @Input({required: true}) + ownerId: EntityId; + + @Input({required: true}) + testScript: () => Observable; + + propagateConfiguration = this.fb.group({ + arguments: this.fb.control({}, notEmptyObjectValidator()), + applyExpressionToResolvedArguments: [false], + relation: this.fb.group({ + direction: [EntitySearchDirection.TO, Validators.required], + relationType: ['Contains', Validators.required], + }), + expression: [calculatedFieldDefaultScript], + output: this.fb.control({ + type: OutputType.Timeseries, + }), + }); + + readonly ScriptLanguage = ScriptLanguage; + readonly CalculatedFieldType = CalculatedFieldType; + readonly OutputType = OutputType; + readonly Directions = Object.values(EntitySearchDirection) as Array; + readonly PropagationDirectionTranslations = PropagationDirectionTranslations; + + functionArgs$ = this.propagateConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)]) + ); + + argumentsEditorCompleter$ = this.propagateConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj ?? {})) + ); + + argumentsHighlightRules$ = this.propagateConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj)) + ); + + private propagateChange: (config: CalculatedFieldPropagationConfiguration) => void = () => { }; + + constructor(private fb: FormBuilder) { + this.propagateConfiguration.get('applyExpressionToResolvedArguments').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe(() => { + this.updatedFormWithScript(); + }) + + this.propagateConfiguration.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((value: CalculatedFieldPropagationConfiguration) => { + this.updatedModel(value); + }) + } + + validate(): ValidationErrors | null { + return this.propagateConfiguration.valid || this.propagateConfiguration.disabled ? null : {invalidPropagateConfig: false}; + } + + writeValue(value: PropagationWithExpression): void { + value.expression = value.expression ?? calculatedFieldDefaultScript; + this.propagateConfiguration.patchValue(value, {emitEvent: false}); + this.updatedFormWithScript(); + setTimeout(() => { + this.propagateConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); + }); + } + + registerOnChange(fn: (config: CalculatedFieldPropagationConfiguration) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_: any): void { } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.propagateConfiguration.disable({emitEvent: false}); + } else { + this.propagateConfiguration.enable({emitEvent: false}); + this.updatedFormWithScript(); + } + } + + onTestScript() { + this.testScript().subscribe((expression) => { + this.propagateConfiguration.get('expression').setValue(expression); + this.propagateConfiguration.get('expression').markAsDirty(); + }) + } + + fetchOptions(searchText: string): Observable> { + const search = searchText ? searchText?.toLowerCase() : ''; + return of(['Contains', 'Manages']).pipe(map(name => name?.filter(option => option.toLowerCase().includes(search)))); + } + + private updatedModel(value: CalculatedFieldPropagationConfiguration): void { + value.type = CalculatedFieldType.PROPAGATION; + this.propagateChange(value); + } + + private updatedFormWithScript() { + if (this.propagateConfiguration.get('applyExpressionToResolvedArguments').value) { + this.propagateConfiguration.get('expression').enable({emitEvent: false}); + } else { + this.propagateConfiguration.get('expression').disable({emitEvent: false}); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module.ts new file mode 100644 index 0000000000..83dc4badf9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module.ts @@ -0,0 +1,44 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + CalculatedFieldOutputModule +} from '@home/components/calculated-fields/components/output/calculated-field-output.module'; +import { + CalculatedFieldArgumentsTableModule +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module'; +import { + PropagationConfigurationComponent +} from '@home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CalculatedFieldOutputModule, + CalculatedFieldArgumentsTableModule, + ], + declarations: [ + PropagationConfigurationComponent, + ], + exports: [ + PropagationConfigurationComponent, + ] +}) +export class PropagationConfigurationModule { } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts index 9e3c52bc4f..d4d4f9d1da 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts @@ -15,7 +15,5 @@ /// export * from './dialog/calculated-field-dialog.component'; -export * from './arguments-table/calculated-field-arguments-table.component'; -export * from './panel/calculated-field-argument-panel.component'; export * from './debug-dialog/calculated-field-debug-dialog.component'; export * from './test-dialog/calculated-field-script-test-dialog.component'; diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.html new file mode 100644 index 0000000000..8b29689b7f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.html @@ -0,0 +1,175 @@ + +
    +
    {{ 'calculated-fields.metrics.metric-settings' | translate }}
    +
    +
    +
    {{ 'calculated-fields.metrics.metric-name' | translate }}
    + + + @if (metricForm.get('name').touched && metricForm.get('name').hasError('required')) { + + warning + + } @else if (metricForm.get('name').touched && metricForm.get('name').hasError('duplicateName')) { + + warning + + } @else if (metricForm.get('name').touched && metricForm.get('name').hasError('pattern')) { + + warning + + } @else if (metricForm.get('name').touched && metricForm.get('name').hasError('maxlength')) { + + warning + + } @else if (metricForm.get('name').touched && metricForm.get('name').hasError('forbiddenName')) { + + warning + + } + +
    +
    +
    {{ 'calculated-fields.metrics.aggregation' | translate }}
    + + + @for (aggFunction of AggFunctions; track aggFunction) { + {{ AggFunctionTranslations.get(aggFunction) | translate }} + } + + +
    + +
    + + + + +
    + {{ 'calculated-fields.metrics.filter' | translate }} +
    +
    +
    +
    + + +
    {{ 'api-usage.tbel' | translate }} +
    +
    +
    +
    +
    + +
    +
    {{ 'calculated-fields.metrics.value-source' | translate }}
    + + + @for (inputType of AggInputTypes; track inputType) { + {{ AggInputTypeTranslations.get(inputType) | translate }} + } + + +
    + @if (this.metricForm.get('input.type').value === AggInputType.key) { +
    +
    {{ 'calculated-fields.argument-name' | translate }}
    + + + @for (argument of arguments; track argument) { + {{ argument }} + } + + @if (metricForm.get('input.key').touched && metricForm.get('input.key').hasError('required')) { + + warning + + } + +
    + } @else { + +
    {{ 'api-usage.tbel' | translate }} +
    +
    + } +
    +
    +
    + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.ts new file mode 100644 index 0000000000..d21db8ece9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.ts @@ -0,0 +1,167 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, Input, OnInit, output } from '@angular/core'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { FormBuilder, FormControl, ValidatorFn, Validators } from '@angular/forms'; +import { charsWithNumRegex } from '@shared/models/regex.constants'; +import { + AggFunction, + AggFunctionTranslations, + AggInputType, + AggInputTypeTranslations, + CalculatedFieldAggMetricValue +} from '@shared/models/calculated-field.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EntityFilter } from '@shared/models/query/query.models'; +import { ScriptLanguage } from '@shared/models/rule-node.models'; +import { TbEditorCompleter } from '@shared/models/ace/completion.models'; +import { AceHighlightRules } from '@shared/models/ace/ace.models'; + +interface CalculatedFieldAggMetricValuePanel extends CalculatedFieldAggMetricValue { + allowFilter: boolean; +} + +@Component({ + selector: 'tb-calculated-field-metrics-panel', + templateUrl: './calculated-field-metrics-panel.component.html', + styleUrl: '../common/calculated-field-panel.scss', +}) +export class CalculatedFieldMetricsPanelComponent implements OnInit { + + @Input() buttonTitle: string; + @Input() metric: CalculatedFieldAggMetricValue; + @Input() usedNames: string[]; + @Input() arguments: Array; + @Input() editorCompleter: TbEditorCompleter; + @Input() highlightRules: AceHighlightRules; + + metricDataApplied = output(); + filterExpanded = false; + functionArgs: Array + + metricForm = this.fb.group({ + name: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], + function: [AggFunction.AVG], + allowFilter: [false], + filter: ['', Validators.required], + input: this.fb.group({ + type: [AggInputType.key], + key: ['', Validators.required], + function: ['', Validators.required], + }) + }); + + entityFilter: EntityFilter; + + readonly AggFunctions = Object.values(AggFunction) as AggFunction[]; + readonly AggFunctionTranslations = AggFunctionTranslations; + readonly ScriptLanguage = ScriptLanguage; + readonly AggInputType = AggInputType; + readonly AggInputTypes = Object.values(AggInputType) as AggInputType[]; + readonly AggInputTypeTranslations = AggInputTypeTranslations; + + constructor( + private fb: FormBuilder, + private popover: TbPopoverComponent + ) { + this.observeFilterAllowChange(); + this.observeInputTypeChange(); + } + + ngOnInit(): void { + const data: CalculatedFieldAggMetricValuePanel = { + ...this.metric, + allowFilter: !!this.metric.filter, + } + this.metricForm.patchValue(data, {emitEvent: false}); + + this.validateFilter(data.allowFilter); + this.validateInputTypeFilter(data.input?.type ?? AggInputType.key); + this.validateInputKey(); + + this.functionArgs = ['ctx', ...this.arguments]; + } + + saveMetric(): void { + const value = this.metricForm.value as CalculatedFieldAggMetricValuePanel; + if (!value.allowFilter) { + delete value.filter; + } + delete value.allowFilter; + this.metricDataApplied.emit(value); + } + + cancel(): void { + this.popover.hide(); + } + + private observeFilterAllowChange(): void { + this.metricForm.get('allowFilter').valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(value => this.validateFilter(value)); + } + + private observeInputTypeChange(): void { + this.metricForm.get('input.type').valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(value => this.validateInputTypeFilter(value)); + } + + private validateFilter(allowFilter = false): void { + if (allowFilter) { + this.metricForm.get('filter').enable({emitEvent: false}); + } else { + this.metricForm.get('filter').disable({emitEvent: false}); + } + this.filterExpanded = allowFilter; + } + + private validateInputTypeFilter(value: AggInputType): void { + const inputForm = this.metricForm.get('input'); + if (value === AggInputType.key) { + inputForm.get('key').enable({emitEvent: false}); + inputForm.get('function').disable({emitEvent: false}); + } else { + inputForm.get('key').disable({emitEvent: false}); + inputForm.get('function').enable({emitEvent: false}); + } + } + + private validateInputKey() { + if (this.metric.input?.type === AggInputType.key && !this.arguments.includes(this.metric.input.key)) { + this.metricForm.get('input.key').setValue(null); + this.metricForm.get('input.key').markAsTouched(); + } + } + + private uniqNameRequired(): ValidatorFn { + return (control: FormControl) => { + const newName = control.value.trim().toLowerCase(); + const isDuplicate = this.usedNames?.some(name => name.toLowerCase() === newName); + + return isDuplicate ? { duplicateName: true } : null; + }; + } + + private forbiddenNameValidator(): ValidatorFn { + return (control: FormControl) => { + const trimmedValue = control.value.trim().toLowerCase(); + const forbiddenNames = ['ctx', 'e', 'pi']; + return forbiddenNames.includes(trimmedValue) ? { forbiddenName: true } : null; + }; + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.html new file mode 100644 index 0000000000..4e05a0bbd5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.html @@ -0,0 +1,113 @@ + +
    +
    + + + +
    {{ 'calculated-fields.metrics.metric-name' | translate }}
    +
    + +
    +
    {{ metric.name }}
    + +
    +
    +
    + + + {{ 'calculated-fields.metrics.aggregation' | translate }} + + +
    {{ AggFunctionTranslations.get(metric.function) | translate }}
    +
    +
    + + + {{ 'calculated-fields.metrics.filtered' | translate }} + + +
    + {{ metric.filter ? 'check_box' : 'check_box_outline_blank' }} +
    +
    +
    + + + {{ 'calculated-fields.metrics.value-source' | translate }} + + +
    {{ AggInputTypeTranslations.get(metric.input.type) | translate }}
    +
    +
    + + + + +
    + + +
    +
    +
    + + +
    +
    + {{ 'calculated-fields.metrics.no-metrics-configured' | translate }} +
    + @if (errorText) { + + } +
    +
    + + @if (maxArgumentsPerCF && metricsFormArray.length >= maxArgumentsPerCF) { +
    + warning + {{ 'calculated-fields.metrics.max-metrics' | translate }} +
    + } +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.ts new file mode 100644 index 0000000000..3689e016b2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.ts @@ -0,0 +1,244 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, + ChangeDetectorRef, + Component, + DestroyRef, + forwardRef, + Input, + Renderer2, + ViewChild, + ViewContainerRef, +} from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, +} from '@angular/forms'; +import { + AggFunctionTranslations, + AggInputTypeTranslations, + CalculatedFieldAggMetric, + CalculatedFieldAggMetricValue, +} from '@shared/models/calculated-field.models'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { isDefinedAndNotNull, isEqual } from '@core/utils'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract'; +import { MatSort, SortDirection } from '@angular/material/sort'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + CalculatedFieldMetricsPanelComponent +} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component'; +import { TbEditorCompleter } from '@shared/models/ace/completion.models'; +import { AceHighlightRules } from '@shared/models/ace/ace.models'; + +@Component({ + selector: 'tb-calculated-field-metrics-table', + templateUrl: './calculated-field-metrics-table.component.html', + styleUrls: [`../calculated-field-arguments/calculated-field-arguments-table.component.scss`], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CalculatedFieldMetricsTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CalculatedFieldMetricsTableComponent), + multi: true + } + ], +}) +export class CalculatedFieldMetricsTableComponent implements ControlValueAccessor, Validator, AfterViewInit { + + @Input() arguments: Array; + @Input() editorCompleter: TbEditorCompleter; + @Input() highlightRules: AceHighlightRules; + + @ViewChild(MatSort, { static: true }) sort: MatSort; + + errorText = ''; + metricsFormArray = this.fb.array([]); + sortOrder = { direction: 'asc' as SortDirection, property: '' }; + dataSource = new CalculatedFieldMetricsDatasource(); + + displayColumns = ['name', 'function', 'filter', 'valueSource', 'actions'] + + readonly AggFunctionTranslations = AggFunctionTranslations; + readonly AggInputTypeTranslations = AggInputTypeTranslations; + readonly maxArgumentsPerCF = getCurrentAuthState(this.store).maxArgumentsPerCF - 2; + + private popoverComponent: TbPopoverComponent; + private propagateChange: (zonesObj: Record) => void = () => {}; + + constructor( + private fb: FormBuilder, + private popoverService: TbPopoverService, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef, + private renderer: Renderer2, + private destroyRef: DestroyRef, + private store: Store + ) { + this.metricsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => { + this.updateDataSource(value); + this.propagateChange(this.getMetricsObject(value)); + }); + } + + ngAfterViewInit(): void { + this.sort.sortChange.asObservable().pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.sortOrder.property = this.sort.active; + this.sortOrder.direction = this.sort.direction; + this.updateDataSource(this.metricsFormArray.value); + }); + } + + registerOnChange(fn: (zonesObj: Record) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void {} + + validate(): ValidationErrors | null { + this.updateErrorText(); + return this.errorText ? { metricsFormArray: false } : null; + } + + onDelete($event: Event, metric: CalculatedFieldAggMetricValue): void { + $event.stopPropagation(); + const index = this.metricsFormArray.controls.findIndex(control => isEqual(control.value, metric)); + this.metricsFormArray.removeAt(index); + this.metricsFormArray.markAsDirty(); + } + + manageMetrics($event: Event, matButton: MatButton, metric = {} as CalculatedFieldAggMetricValue): void { + $event?.stopPropagation(); + if (this.popoverComponent && !this.popoverComponent.tbHidden) { + this.popoverComponent.hide(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const index = this.metricsFormArray.controls.findIndex(control => isEqual(control.value, metric)); + const isExists = index !== -1; + const ctx = { + index, + metric, + buttonTitle: isExists ? 'action.apply' : 'action.add', + usedNames: this.metricsFormArray.value.map(({ name }) => name).filter(name => name !== metric.name), + arguments: this.arguments, + editorCompleter: this.editorCompleter, + highlightRules: this.highlightRules + }; + this.popoverComponent = this.popoverService.displayPopover({ + trigger, + renderer: this.renderer, + componentType: CalculatedFieldMetricsPanelComponent, + hostView: this.viewContainerRef, + preferredPlacement: isExists ? ['leftOnly', 'leftTopOnly', 'leftBottomOnly'] : ['rightOnly', 'rightTopOnly', 'rightBottomOnly'], + context: ctx, + isModal: true + }); + this.popoverComponent.tbComponentRef.instance.metricDataApplied.subscribe((value) => { + this.popoverComponent.hide(); + if (isExists) { + this.metricsFormArray.at(index).setValue(value); + } else { + this.metricsFormArray.push(this.fb.control(value)); + } + this.cd.markForCheck(); + }); + } + } + + private updateDataSource(value: CalculatedFieldAggMetricValue[]): void { + const sortedValue = this.sortData(value); + this.dataSource.loadData(sortedValue); + } + + private updateErrorText(): void { + if (!this.metricsFormArray.controls.length) { + this.errorText = 'calculated-fields.metrics.metrics-empty'; + } else { + this.errorText = ''; + } + } + + private getMetricsObject(value: CalculatedFieldAggMetricValue[]): Record { + return value.reduce((acc, metricValue) => { + const { name, ...metric } = metricValue; + acc[name] = metric; + return acc; + }, {} as Record); + } + + writeValue(metrics: Record): void { + this.metricsFormArray.clear(); + this.populateZonesFormArray(metrics); + } + + private populateZonesFormArray(metrics: Record): void { + Object.keys(metrics).forEach(key => { + const value: CalculatedFieldAggMetricValue = { + ...metrics[key], + name: key + }; + this.metricsFormArray.push(this.fb.control(value), { emitEvent: false }); + }); + this.metricsFormArray.updateValueAndValidity(); + } + + private getSortValue(metric: CalculatedFieldAggMetricValue, column: string): string { + switch (column) { + case 'function': + return metric.function; + case 'valueSource': + return metric.input?.type; + case 'filter': + return isDefinedAndNotNull(metric.filter).toString(); + default: + return metric.name; + } + } + + private sortData(data: CalculatedFieldAggMetricValue[]): CalculatedFieldAggMetricValue[] { + return data.sort((a, b) => { + const valA = this.getSortValue(a, this.sortOrder.property) ?? ''; + const valB = this.getSortValue(b, this.sortOrder.property) ?? ''; + return (this.sortOrder.direction === 'asc' ? 1 : -1) * valA.localeCompare(valB); + }); + } +} + +class CalculatedFieldMetricsDatasource extends TbTableDatasource { + constructor() { + super(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html new file mode 100644 index 0000000000..914226b777 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html @@ -0,0 +1,81 @@ + +
    +
    +
    + {{ 'calculated-fields.aggregation-path-related-entities' | translate }} +
    +
    + + {{ 'calculated-fields.direction' | translate }} + + @for (direction of Directions; track direction) { + {{ PropagationDirectionTranslations.get(direction) | translate }} + } + + + + +
    +
    +
    +
    + {{ 'calculated-fields.arguments' | translate }} +
    + +
    +
    +
    + {{ 'calculated-fields.metrics.metrics' | translate }} +
    + + + +
    + +
    + +
    +
    calculated-fields.use-latest-timestamp
    +
    +
    +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.scss new file mode 100644 index 0000000000..6a8faea40f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.scss @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 ::ng-deep { + .simpleMode { + min-width: 0; + + .mat-slide { + overflow: hidden; + + .mdc-form-field { + width: 100%; + + .mdc-label { + min-width: 0; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts new file mode 100644 index 0000000000..c53f400916 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts @@ -0,0 +1,156 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, forwardRef, Input } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { EntityId } from '@shared/models/id/entity-id'; +import { Observable, of } from 'rxjs'; +import { + CalculatedFieldOutput, + CalculatedFieldRelatedAggregationConfiguration, + CalculatedFieldType, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights, + notEmptyObjectValidator, + OutputType, + PropagationDirectionTranslations +} from '@shared/models/calculated-field.models'; +import { map } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ScriptLanguage } from '@app/shared/models/rule-node.models'; +import { EntitySearchDirection } from '@shared/models/relation.models'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; + +@Component({ + selector: 'tb-related-entities-aggregation-component', + templateUrl: './related-entities-aggregation-component.component.html', + styleUrl: './related-entities-aggregation-component.component.scss', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RelatedEntitiesAggregationComponentComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => RelatedEntitiesAggregationComponentComponent), + multi: true + } + ], +}) +export class RelatedEntitiesAggregationComponentComponent implements ControlValueAccessor, Validator { + + @Input({required: true}) + entityId: EntityId; + + @Input({required: true}) + tenantId: string; + + @Input({required: true}) + entityName: string; + + readonly ScriptLanguage = ScriptLanguage; + readonly CalculatedFieldType = CalculatedFieldType; + readonly OutputType = OutputType; + readonly Directions = Object.values(EntitySearchDirection) as Array; + readonly PropagationDirectionTranslations = PropagationDirectionTranslations; + readonly minAllowedDeduplicationIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedDeduplicationIntervalInSecForCF; + + relatedAggregationConfiguration = this.fb.group({ + relation: this.fb.group({ + direction: [EntitySearchDirection.FROM, Validators.required], + relationType: ['Contains', Validators.required], + }), + arguments: this.fb.control({}, notEmptyObjectValidator()), + metrics: this.fb.control({}, notEmptyObjectValidator()), + deduplicationIntervalInSec: [this.minAllowedDeduplicationIntervalInSecForCF], + output: this.fb.control({ + type: OutputType.Timeseries, + }), + useLatestTs: [false] + }); + + arguments$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => Object.keys(argumentsObj)) + ); + + argumentsEditorCompleter$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj ?? {})) + ); + + argumentsHighlightRules$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj)) + ); + + private readonly minAllowedScheduledUpdateIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedScheduledUpdateIntervalInSecForCF; + private propagateChange: (config: CalculatedFieldRelatedAggregationConfiguration) => void = () => { }; + + constructor(private fb: FormBuilder, + private store: Store) { + + this.relatedAggregationConfiguration.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((value: CalculatedFieldRelatedAggregationConfiguration) => { + this.updatedModel(value); + }) + } + + validate(): ValidationErrors | null { + return this.relatedAggregationConfiguration.valid || this.relatedAggregationConfiguration.disabled ? null : {invalidPropagateConfig: false}; + } + + writeValue(value: CalculatedFieldRelatedAggregationConfiguration): void { + this.relatedAggregationConfiguration.patchValue(value, {emitEvent: false}); + setTimeout(() => { + this.relatedAggregationConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); + }); + } + + registerOnChange(fn: (config: CalculatedFieldRelatedAggregationConfiguration) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_: any): void { } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.relatedAggregationConfiguration.disable({emitEvent: false}); + } else { + this.relatedAggregationConfiguration.enable({emitEvent: false}); + } + } + + fetchOptions(searchText: string): Observable> { + const search = searchText ? searchText?.toLowerCase() : ''; + return of(['Contains', 'Manages']).pipe(map(name => name?.filter(option => option.toLowerCase().includes(search)))); + } + + private updatedModel(value: CalculatedFieldRelatedAggregationConfiguration): void { + value.type = CalculatedFieldType.RELATED_ENTITIES_AGGREGATION; + value.scheduledUpdateInterval = this.minAllowedScheduledUpdateIntervalInSecForCF; + this.propagateChange(value); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module.ts new file mode 100644 index 0000000000..b272f1e965 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module.ts @@ -0,0 +1,53 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + CalculatedFieldOutputModule +} from '@home/components/calculated-fields/components/output/calculated-field-output.module'; +import { + CalculatedFieldArgumentsTableModule +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module'; +import { + RelatedEntitiesAggregationComponentComponent +} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component'; +import { + CalculatedFieldMetricsTableComponent +} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component'; +import { + CalculatedFieldMetricsPanelComponent +} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CalculatedFieldOutputModule, + CalculatedFieldArgumentsTableModule, + ], + declarations: [ + RelatedEntitiesAggregationComponentComponent, + CalculatedFieldMetricsTableComponent, + CalculatedFieldMetricsPanelComponent + ], + exports: [ + RelatedEntitiesAggregationComponentComponent, + ] +}) +export class RelatedEntitiesAggregationComponentModule { +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html new file mode 100644 index 0000000000..cfab9d9def --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html @@ -0,0 +1,99 @@ + +
    +
    +
    {{ 'calculated-fields.arguments' | translate }}
    + +
    +
    +
    + {{ (isScript ? 'calculated-fields.type.script' : 'calculated-fields.expression') | translate }} +
    + + +
    +
    + @if (simpleConfiguration.get('expressionSIMPLE').errors && simpleConfiguration.get('expressionSIMPLE').touched) { + + @if (simpleConfiguration.get('expressionSIMPLE').hasError('required')) { + {{ 'calculated-fields.hint.expression-required' | translate }} + } @else if (simpleConfiguration.get('expressionSIMPLE').hasError('pattern')) { + {{ 'calculated-fields.hint.expression-invalid' | translate }} + } @else if (simpleConfiguration.get('expressionSIMPLE').hasError('maxLength')) { + {{ 'calculated-fields.hint.expression-max-length' | translate }} + } + + } @else { + {{ 'calculated-fields.hint.expression' | translate }} + } +
    +
    + +
    {{ 'api-usage.tbel' | translate }} +
    + +
    +
    + +
    +
    +
    + +
    + +
    + calculated-fields.use-latest-timestamp +
    +
    +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts new file mode 100644 index 0000000000..065556081c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts @@ -0,0 +1,207 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { oneSpaceInsideRegex } from '@shared/models/regex.constants'; +import { + calculatedFieldDefaultScript, + CalculatedFieldScriptConfiguration, + CalculatedFieldSimpleConfiguration, + CalculatedFieldSimpleOutput, + CalculatedFieldType, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights, + OutputType +} from '@shared/models/calculated-field.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { deepClone } from '@core/utils'; +import { EntityId } from '@shared/models/id/entity-id'; +import { Observable } from 'rxjs'; +import { ScriptLanguage } from '@shared/models/rule-node.models'; +import { map } from 'rxjs/operators'; + +type SimpeConfiguration = CalculatedFieldSimpleConfiguration | CalculatedFieldScriptConfiguration; + +@Component({ + selector: 'tb-simple-configuration', + templateUrl: './simple-configuration.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SimpleConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => SimpleConfigurationComponent), + multi: true + } + ], +}) +export class SimpleConfigurationComponent implements ControlValueAccessor, Validator, OnChanges { + + @Input() + isScript: boolean; + + @Input({required: true}) + entityId: EntityId; + + @Input({required: true}) + tenantId: string; + + @Input({required: true}) + entityName: string; + + @Input({required: true}) + ownerId: EntityId; + + @Input({required: true}) + testScript: () => Observable; + + simpleConfiguration = this.fb.group({ + arguments: this.fb.control({}), + expressionSIMPLE: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], + expressionSCRIPT: [calculatedFieldDefaultScript], + output: this.fb.control({ + name: '', + type: OutputType.Timeseries, + decimalsByDefault: null + }), + useLatestTs: [false] + }); + + readonly ScriptLanguage = ScriptLanguage; + readonly OutputType = OutputType; + + functionArgs$ = this.simpleConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)]) + ); + + argumentsEditorCompleter$ = this.simpleConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj ?? {})) + ); + + argumentsHighlightRules$ = this.simpleConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj)) + ); + + private propagateChange: (config: SimpeConfiguration) => void = () => { }; + + constructor(private fb: FormBuilder) { + this.simpleConfiguration.get('output').valueChanges.pipe( + takeUntilDestroyed(), + ).subscribe(() => { + this.toggleScopeByOutputType(); + }); + + this.simpleConfiguration.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((value) => { + const { expressionSIMPLE, expressionSCRIPT, ...config } = value; + const cfConfig = config as SimpeConfiguration; + cfConfig.expression = this.isScript ? expressionSCRIPT : expressionSIMPLE; + this.updatedModel(cfConfig); + }) + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (change.currentValue !== change.previousValue) { + if (propName === 'isScript') { + this.updatedFormWithScript(); + if (!change.firstChange) { + this.simpleConfiguration.updateValueAndValidity(); + } + } + } + } + } + + validate(): ValidationErrors | null { + return this.simpleConfiguration.valid || this.simpleConfiguration.disabled ? null : {invalidSimpleConfig: false}; + } + + writeValue(value: SimpeConfiguration): void { + const formValue: any = deepClone(value); + if (this.isScript) { + formValue.expressionSCRIPT = formValue.expression ?? calculatedFieldDefaultScript; + } else { + formValue.expressionSIMPLE = formValue.expression; + } + this.simpleConfiguration.patchValue(formValue, {emitEvent: false}); + this.updatedFormWithScript(); + setTimeout(() => { + this.simpleConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); + }); + } + + registerOnChange(fn: (config: SimpeConfiguration) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_: any): void { + } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.simpleConfiguration.disable({emitEvent: false}); + } else { + this.simpleConfiguration.enable({emitEvent: false}); + this.updatedFormWithScript(); + } + } + + onTestScript() { + this.testScript().subscribe((expression) => { + this.simpleConfiguration.get('expressionSCRIPT').setValue(expression); + this.simpleConfiguration.get('expressionSCRIPT').markAsDirty(); + }) + } + + private updatedModel(value: SimpeConfiguration): void { + value.type = this.isScript ? CalculatedFieldType.SCRIPT : CalculatedFieldType.SIMPLE; + this.propagateChange(value); + } + + private updatedFormWithScript() { + if (this.isScript) { + this.simpleConfiguration.get('expressionSIMPLE').disable({emitEvent: false}); + this.simpleConfiguration.get('expressionSCRIPT').enable({emitEvent: false}); + } else { + this.simpleConfiguration.get('expressionSIMPLE').enable({emitEvent: false}); + this.simpleConfiguration.get('expressionSCRIPT').disable({emitEvent: false}); + } + this.toggleScopeByOutputType(); + } + + private toggleScopeByOutputType(): void { + if (this.isScript || this.simpleConfiguration.get('output').value.type === OutputType.Attribute) { + this.simpleConfiguration.get('useLatestTs').disable({emitEvent: false}); + } else { + this.simpleConfiguration.get('useLatestTs').enable({emitEvent: false}); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts new file mode 100644 index 0000000000..2e5e14426e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts @@ -0,0 +1,44 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + SimpleConfigurationComponent +} from '@home/components/calculated-fields/components/simple-configuration/simple-configuration.component'; +import { + CalculatedFieldOutputModule +} from '@home/components/calculated-fields/components/output/calculated-field-output.module'; +import { + CalculatedFieldArgumentsTableModule +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CalculatedFieldOutputModule, + CalculatedFieldArgumentsTableModule, + ], + declarations: [ + SimpleConfigurationComponent, + ], + exports: [ + SimpleConfigurationComponent + ] +}) +export class SimpleConfigurationModule {} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index 0099fa83a7..44d1e2efc3 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts @@ -649,9 +649,9 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC } private hideToolbarSetting(): boolean { - if (this.dashboard.configuration.settings && - isDefined(this.dashboard.configuration.settings.hideToolbar)) { - return this.dashboard.configuration.settings.hideToolbar; + if (isDefined(this.dashboard.configuration?.settings?.hideToolbar)) { + const canApplyHideSetting = !this.forceFullscreen || this.isMobileApp; + return this.dashboard.configuration.settings.hideToolbar && canApplyHideSetting; } else { return false; } diff --git a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.html b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.html index 56ec0c6561..7fa3da0cae 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.html +++ b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + @@ -72,6 +72,12 @@ device.lwm2m-security-config.client-public-key-hint + @@ -103,5 +109,11 @@
    + diff --git a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.ts b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.ts index ca91c14682..ceb0b0da78 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.ts +++ b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.ts @@ -14,20 +14,21 @@ /// limitations under the License. /// -import { Component, forwardRef, OnDestroy } from '@angular/core'; +import { Component, forwardRef, Input, OnDestroy } from '@angular/core'; import { ControlValueAccessor, - UntypedFormBuilder, - UntypedFormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, ValidationErrors, Validator, Validators } from '@angular/forms'; import { getDefaultClientSecurityConfig, - getDefaultServerSecurityConfig, Lwm2mClientKeyTooltipTranslationsMap, + getDefaultServerSecurityConfig, + Lwm2mClientKeyTooltipTranslationsMap, Lwm2mSecurityConfigModels, Lwm2mSecurityType, Lwm2mSecurityTypeTranslationMap @@ -35,6 +36,11 @@ import { import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { isDefinedAndNotNull } from '@core/utils'; +import { DeviceId } from "@shared/models/id/device-id"; +import { DeviceService } from "@core/http/device.service"; +import { ActionNotificationShow } from "@core/notification/notification.actions"; +import { Store } from "@ngrx/store"; +import { AppState } from "@core/core.state"; @Component({ selector: 'tb-device-credentials-lwm2m', @@ -65,7 +71,12 @@ export class DeviceCredentialsLwm2mComponent implements ControlValueAccessor, Va private destroy$ = new Subject(); private propagateChange = (v: any) => {}; - constructor(private fb: UntypedFormBuilder) { + @Input() + deviceId: DeviceId; + + constructor(protected store: Store, + private fb: UntypedFormBuilder, + private deviceService: DeviceService) { this.lwm2mConfigFormGroup = this.initLwm2mConfigForm(); } @@ -101,6 +112,41 @@ export class DeviceCredentialsLwm2mComponent implements ControlValueAccessor, Va this.destroy$.complete(); } + /** + * AbstractRpcController -> rpcController + * - API + * "/api/plugins/rpc/twoway/${this.deviceId.id}" + * - DiscoveryAll + * requestBody = "{\"method\":\"DiscoverAll\"}"; + * - "Registration Update Trigger", + * requestBody = "{\"method\": \"Execute\", \"params\": {\"id\": \"/1/0/8\"}} + * - "Bootstrap-Request Trigger" + * requestBody = "{\"method\": \"Execute\", \"params\": {\"id\": \"/1/0/9\"}} + */ + + public rebootDevice(isBootstrapServer: boolean): void { + this.deviceService.rebootDevice(this.deviceId.id, isBootstrapServer).subscribe(responseReboot => { + if (responseReboot.result === 'SUCCESS') { + this.store.dispatch(new ActionNotificationShow( + { + message: responseReboot.msg, + type: 'success', + duration: 1500, + verticalPosition: 'top', + horizontalPosition: 'left' + })); + } else { + this.store.dispatch(new ActionNotificationShow( + { + message: responseReboot.msg, + type: 'error', + verticalPosition: 'top', + horizontalPosition: 'left' + })); + } + }); + } + private initClientSecurityConfig(config: Lwm2mSecurityConfigModels): void { this.lwm2mConfigFormGroup.patchValue(config, {emitEvent: false}); this.securityConfigClientUpdateValidators(config.client.securityConfigClientMode); diff --git a/ui-ngx/src/app/modules/home/components/device/device-credentials.component.html b/ui-ngx/src/app/modules/home/components/device/device-credentials.component.html index 34c446f759..144f2059c3 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-credentials.component.html +++ b/ui-ngx/src/app/modules/home/components/device/device-credentials.component.html @@ -81,7 +81,8 @@ - +
    diff --git a/ui-ngx/src/app/modules/home/components/device/device-credentials.component.ts b/ui-ngx/src/app/modules/home/components/device/device-credentials.component.ts index 012bdf9c9c..6c56d2da84 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-credentials.component.ts +++ b/ui-ngx/src/app/modules/home/components/device/device-credentials.component.ts @@ -36,6 +36,7 @@ import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { generateSecret, isDefinedAndNotNull } from '@core/utils'; import { coerceBoolean } from '@shared/decorators/coercion'; +import { DeviceId } from "@shared/models/id/device-id"; @Component({ selector: 'tb-device-credentials', @@ -88,6 +89,8 @@ export class DeviceCredentialsComponent implements ControlValueAccessor, OnInit, credentialTypeNamesMap = credentialTypeNames; + deviceId: DeviceId; + private propagateChange = null; private propagateChangePending = false; @@ -126,6 +129,7 @@ export class DeviceCredentialsComponent implements ControlValueAccessor, OnInit, writeValue(value: DeviceCredentials | null): void { if (isDefinedAndNotNull(value)) { + this.deviceId = value.deviceId; const credentialsType = this.credentialsTypes.includes(value.credentialsType) ? value.credentialsType : this.credentialsTypes[0]; this.deviceCredentialsFormGroup.patchValue({ credentialsType, diff --git a/ui-ngx/src/app/modules/home/components/entity/contact-based.component.ts b/ui-ngx/src/app/modules/home/components/entity/contact-based.component.ts index ff052313ff..2db5dd3c97 100644 --- a/ui-ngx/src/app/modules/home/components/entity/contact-based.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/contact-based.component.ts @@ -24,6 +24,7 @@ import { EntityComponent } from './entity.component'; import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; import { CountryData } from '@shared/models/country.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { validateEmail } from '@app/core/utils'; @Directive() export abstract class ContactBasedComponent> extends EntityComponent implements AfterViewInit { @@ -50,7 +51,7 @@ export abstract class ContactBasedComponent> exten entityForm.addControl('address', this.fb.control(entity ? entity.address : '', [])); entityForm.addControl('address2', this.fb.control(entity ? entity.address2 : '', [])); entityForm.addControl('phone', this.fb.control(entity ? entity.phone : '', [Validators.maxLength(255)])); - entityForm.addControl('email', this.fb.control(entity ? entity.email : '', [Validators.email])); + entityForm.addControl('email', this.fb.control(entity ? entity.email : '', [validateEmail])); return entityForm; } diff --git a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.ts index e19407a687..c3083d2180 100644 --- a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.ts @@ -53,6 +53,7 @@ export class EntityDebugSettingsPanelComponent extends PageComponent implements @Input({ transform: booleanAttribute }) failuresEnabled = false; @Input({ transform: booleanAttribute }) allEnabled = false; + @Input() entityLabel = ''; @Input() entityType: EntityType; @Input() allEnabledUntil = 0; @Input() maxDebugModeDuration = getCurrentAuthState(this.store).maxDebugModeDurationMinutes * MINUTE; @@ -64,7 +65,6 @@ export class EntityDebugSettingsPanelComponent extends PageComponent implements maxMessagesCount: string; maxTimeFrameDuration: number; initialAllEnabled: boolean; - entityLabel: string; isDebugAllActive$ = this.debugAllControl.valueChanges.pipe( startWith(this.debugAllControl.value), @@ -109,7 +109,9 @@ export class EntityDebugSettingsPanelComponent extends PageComponent implements this.onFailuresControl.patchValue(this.failuresEnabled); this.debugAllControl.patchValue(this.allEnabled); this.initialAllEnabled = this.allEnabled || this.allEnabledUntil > new Date().getTime(); - this.entityLabel = entityTypeTranslations.has(this.entityType) ? entityTypeTranslations.get(this.entityType).type : 'debug-settings.entity'; + if (!this.entityLabel) { + this.entityLabel = entityTypeTranslations.has(this.entityType) ? entityTypeTranslations.get(this.entityType).type : 'debug-settings.entity'; + } } onCancel(): void { diff --git a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.model.ts b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.model.ts index 0af9da52f2..f3b19d3737 100644 --- a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.model.ts +++ b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.model.ts @@ -28,6 +28,7 @@ export interface EntityDebugSettingPanelConfig { maxDebugModeDuration?: number; additionalActionConfig?: AdditionalDebugActionConfig; entityType: EntityType; + entityLabel?: string; } onSettingsAppliedFn: (settings: EntityDebugSettings) => void; } diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html index 25bb1d9700..736e716bec 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html @@ -45,6 +45,7 @@ diff --git a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html index 4c350beee7..6ec45f2d16 100644 --- a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html @@ -17,7 +17,7 @@ -->
    -

    {{ filter.filter }}

    +

    {{ filter.filter | customTranslate }}

    +
    + + +
    +
    + + +
    +
    + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss new file mode 100644 index 0000000000..b32c3933c5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 ::ng-deep { + .mat-mdc-dialog-content { + display: flex; + flex-direction: column; + height: 100%; + padding: 0 !important; + } +} diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts new file mode 100644 index 0000000000..6216f087b1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts @@ -0,0 +1,116 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, Component, Inject, SkipSelf, ViewChild } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms'; +import { EntityType } from '@shared/models/entity-type.models'; +import { map } from 'rxjs/operators'; +import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component"; +import { ErrorStateMatcher } from "@angular/material/core"; +import { Resource, ResourceType } from "@shared/models/resource.models"; +import { ResourceService } from "@core/http/resource.service"; + +export interface ResourcesDialogData { + resources?: Resource; + isAdd?: boolean; +} + +@Component({ + selector: 'tb-resources-dialog', + templateUrl: './resources-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: ResourcesDialogComponent}], + styleUrls: ['./resources-dialog.component.scss'] +}) +export class ResourcesDialogComponent extends DialogComponent implements ErrorStateMatcher, AfterViewInit { + + readonly entityType = EntityType; + + ResourceType = ResourceType; + + isAdd = false; + + submitted = false; + + resources: Resource; + + @ViewChild('resourcesComponent', {static: true}) resourcesComponent: ResourcesLibraryComponent; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ResourcesDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + private resourceService: ResourceService) { + super(store, router, dialogRef); + + if (this.data.isAdd) { + this.isAdd = true; + } + + if (this.data.resources) { + this.resources = this.data.resources; + } + } + + ngAfterViewInit(): void { + if (this.isAdd) { + setTimeout(() => { + this.resourcesComponent.entityForm.markAsDirty(); + }, 0); + } + } + + isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.submitted = true; + if (this.resourcesComponent.entityForm.valid) { + const resource = {...this.resourcesComponent.entityFormValue()}; + if (Array.isArray(resource.data)) { + const resources = []; + resource.data.forEach((data, index) => { + resources.push({ + resourceType: resource.resourceType, + data, + fileName: resource.fileName[index], + title: resource.title + }); + }); + this.resourceService.saveResources(resources, {resendRequest: true}).pipe( + map((response) => response[0]) + ).subscribe(result => this.dialogRef.close(result)); + } else { + if (resource.resourceType !== ResourceType.GENERAL) { + delete resource.descriptor; + } + this.resourceService.saveResource(resource).subscribe(result => this.dialogRef.close(result)); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html similarity index 64% rename from ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html rename to ui-ngx/src/app/modules/home/components/resources/resources-library.component.html index ebb946ccbc..819753e105 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
    +
    @@ -66,26 +66,29 @@ {{ 'resource.title-max-length' | translate }} - - -
    - - resource.file-name - - -
    + @if (isAdd || ((isAdd || isEdit) && entityForm.get('resourceType').value === resourceType.GENERAL)) { + + + } @else { +
    + + resource.file-name + + +
    + }
    diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.ts b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts similarity index 81% rename from ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.ts rename to ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts index 971961ff68..977eb6873a 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.ts +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, Inject, Input, OnDestroy, OnInit, Optional } from '@angular/core'; import { Subject } from 'rxjs'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -40,8 +40,16 @@ import { getCurrentAuthState } from '@core/auth/auth.selectors'; }) export class ResourcesLibraryComponent extends EntityComponent implements OnInit, OnDestroy { + @Input() + standalone = false; + + @Input() + resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.GENERAL]; + + @Input() + defaultResourceType = ResourceType.LWM2M_MODEL; + readonly resourceType = ResourceType; - readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS]; readonly resourceTypesTranslationMap = ResourceTypeTranslationMap; readonly maxResourceSize = getCurrentAuthState(this.store).maxResourceSize; @@ -49,8 +57,8 @@ export class ResourcesLibraryComponent extends EntityComponent impleme constructor(protected store: Store, protected translate: TranslateService, - @Inject('entity') protected entityValue: Resource, - @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, + @Optional() @Inject('entity') protected entityValue: Resource, + @Optional() @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, public fb: FormBuilder, protected cd: ChangeDetectorRef) { super(store, fb, entityValue, entitiesTableConfigValue, cd); @@ -82,10 +90,19 @@ export class ResourcesLibraryComponent extends EntityComponent impleme title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], resourceType: [entity?.resourceType ? entity.resourceType : ResourceType.LWM2M_MODEL, Validators.required], fileName: [entity ? entity.fileName : null, Validators.required], - data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []] + data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []], + descriptor: this.fb.group({ + mediaType: [''] + }) }); } + mediaTypeChange(mediaType: string): void { + if (this.entityForm.get('resourceType').value === ResourceType.GENERAL) { + this.entityForm.get('descriptor').get('mediaType').patchValue(mediaType); + } + } + updateForm(entity: Resource): void { this.entityForm.patchValue(entity); } @@ -95,6 +112,10 @@ export class ResourcesLibraryComponent extends EntityComponent impleme if (this.isEdit && this.entityForm && !this.isAdd) { this.entityForm.get('resourceType').disable({ emitEvent: false }); this.entityForm.get('fileName').disable({ emitEvent: false }); + this.entityForm.get('data').disable({ emitEvent: false }); + } + if (this.isAdd && this.resourceTypes.length === 1) { + this.entityForm.get('resourceType').disable({ emitEvent: false }); } } @@ -138,7 +159,7 @@ export class ResourcesLibraryComponent extends EntityComponent impleme private observeResourceTypeChange(): void { this.entityForm.get('resourceType').valueChanges.pipe( - startWith(ResourceType.LWM2M_MODEL), + startWith(this.defaultResourceType || ResourceType.LWM2M_MODEL), takeUntil(this.destroy$) ).subscribe((type: ResourceType) => this.onResourceTypeChange(type)); } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html index edce8b12a9..a542d3e07d 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html @@ -39,6 +39,6 @@ > diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html index 5966448e9f..73b24d4274 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html @@ -70,6 +70,17 @@ {{ 'rule-node-config.ai.user-prompt-blank' | translate }} + +
    diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts index e4b21b3f18..dc50051a8a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts @@ -24,6 +24,8 @@ import { AiModel, AiRuleNodeResponseFormatTypeOnlyText, ResponseFormat } from '@ import { deepTrim } from '@core/utils'; import { TranslateService } from '@ngx-translate/core'; import { jsonRequired } from '@shared/components/json-object-edit.component'; +import { Resource, ResourceType } from "@shared/models/resource.models"; +import { ResourcesDialogComponent, ResourcesDialogData } from "@home/components/resources/resources-dialog.component"; @Component({ selector: 'tb-external-node-ai-config', @@ -38,6 +40,9 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { responseFormat = ResponseFormat; + EntityType = EntityType; + ResourceType = ResourceType; + constructor(private fb: UntypedFormBuilder, private translate: TranslateService, private dialog: MatDialog) { @@ -53,6 +58,7 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { modelId: [configuration?.modelId ?? null, [Validators.required]], systemPrompt: [configuration?.systemPrompt ?? '', [Validators.maxLength(500_000), Validators.pattern(/.*\S.*/)]], userPrompt: [configuration?.userPrompt ?? '', [Validators.required, Validators.maxLength(500_000), Validators.pattern(/.*\S.*/)]], + resourceIds: [configuration?.resourceIds ?? []], responseFormat: this.fb.group({ type: [configuration?.responseFormat?.type ?? ResponseFormat.JSON, []], schema: [configuration?.responseFormat?.schema ?? null, [jsonRequired]], @@ -117,5 +123,23 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { this.aiConfigForm.get(formControl).markAsDirty(); } }); + }; + + createAiResources(name: string, formControl: string) { + this.dialog.open(ResourcesDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + resources: {title: name, resourceType: ResourceType.GENERAL}, + isAdd: true + } + }).afterClosed() + .subscribe((resource) => { + if (resource) { + const resourceIds = [...(this.aiConfigForm.get(formControl).value || []), resource.id.id]; + this.aiConfigForm.get(formControl).patchValue(resourceIds); + this.aiConfigForm.get(formControl).markAsDirty(); + } + }); } } diff --git a/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.html b/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.html index d80892664d..2bf50da468 100644 --- a/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.html @@ -38,7 +38,7 @@
    - +
    @@ -60,6 +60,7 @@
    -
    {{ 'version-control.export-credentials' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.scss b/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.scss index a72bd2ca77..e925c79b0d 100644 --- a/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.scss +++ b/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.scss @@ -52,7 +52,6 @@ padding: 0 8px 8px; tb-branch-autocomplete { min-width: 200px; - max-width: 200px; display: block; } } diff --git a/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.html b/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.html index e97e02190a..e6d9ec34bc 100644 --- a/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.html @@ -15,76 +15,79 @@ limitations under the License. --> -
    - -

    {{ 'version-control.create-entities-version' | translate }}

    - -
    - - -
    -
    - - - - version-control.version-name - - - {{ 'version-control.version-name-required' | translate }} - +@if (!versionCreateResult$) { +
    + +

    {{ 'version-control.create-entities-version' | translate }}

    + +
    + + + +
    + + + + version-control.version-name + + + {{ 'version-control.version-name-required' | translate }} + + +
    + + version-control.default-sync-strategy + + @for (strategy of syncStrategies; track strategy) { + + {{syncStrategyTranslations.get(strategy) | translate}} + + } + + + + + +
    + +
    - - version-control.default-sync-strategy - - - {{syncStrategyTranslations.get(strategy) | translate}} - - - - - - - -
    - - -
    -
    -
    -
    -
    -
    - -
    - +} @else { +
    + @if ((versionCreateResult$ | async)?.done || hasError) { +
    + +
    + } @else {
    version-control.creating-version
    -
    -
    + } +} diff --git a/ui-ngx/src/app/modules/home/components/vc/complex-version-load.component.html b/ui-ngx/src/app/modules/home/components/vc/complex-version-load.component.html index 17e6663a03..885058c9c9 100644 --- a/ui-ngx/src/app/modules/home/components/vc/complex-version-load.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/complex-version-load.component.html @@ -15,59 +15,63 @@ limitations under the License. --> -
    - -

    {{ 'version-control.restore-entities-from-version' | translate: {versionName} }}

    - -
    - - -
    - - -
    -
    - - {{ 'version-control.rollback-on-error' | translate }} - -
    -
    - - -
    -
    -
    -
    +@if (!versionLoadResult$) { +
    + +

    {{ 'version-control.restore-entities-from-version' | translate: {versionName} }}

    + +
    + + +
    + + +
    +
    + + {{ 'version-control.rollback-on-error' | translate }} + +
    +
    + + +
    +
    +} @else { +
    {{ 'version-control.no-entities-restored' | translate }}
    -
    -
    {{ entityTypeLoadResultMessage(entityTypeLoadResult) }}
    -
    - -
    - +
    + @for (entityTypeLoadResult of entityTypeLoadResults; track entityTypeLoadResult.entityType) { +
    {{ entityTypeLoadResultMessage(entityTypeLoadResult) }}
    + } + @if ((versionLoadResult$ | async)?.done || hasError) { +
    + +
    + } @else {
    version-control.restoring-entities-from-version
    -
    -
    + } +} diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.html b/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.html index 8fdf22f6cd..b2160e8ab8 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.html @@ -18,86 +18,90 @@
    version-control.entities-to-export
    -
    - - -
    - -
    -
    {{ entityTypeText(entityTypeFormGroup) }}
    -
    -
    - - -
    -
    -
    - -
    - - -
    - - version-control.sync-strategy - - - {{ 'version-control.default' | translate }} - - - {{syncStrategyTranslations.get(strategy) | translate}} - - - -
    - - {{ 'version-control.export-credentials' | translate }} - - - {{ 'version-control.export-attributes' | translate }} - - - {{ 'version-control.export-relations' | translate }} - - - {{ 'version-control.export-calculated-fields' | translate }} - + @for (entityTypeFormGroup of entityTypesFormGroupArray(); track entityTypeFormGroup; let index = $index, isLast = $last) { +
    + + +
    + +
    +
    {{ entityTypeText(entityTypeFormGroup) }}
    +
    +
    + + +
    +
    +
    + +
    + + +
    + + version-control.sync-strategy + + + {{ 'version-control.default' | translate }} + + @for (strategy of syncStrategies; track strategy) { + + {{syncStrategyTranslations.get(strategy) | translate}} + + } + + +
    + + {{ 'version-control.export-credentials' | translate }} + + + {{ 'version-control.export-attributes' | translate }} + + + {{ 'version-control.export-relations' | translate }} + + + {{ 'version-control.export-calculated-fields' | translate }} + +
    +
    + + {{ 'version-control.all-entities' | translate }} + + @if (!entityTypeFormGroup.get('config').get('allEntities').value) { + + + } +
    -
    - - {{ 'version-control.all-entities' | translate }} - - - -
    -
    - -
    -
    - version-control.no-entities-to-export-prompt -
    + +
    + } @empty { + version-control.no-entities-to-export-prompt + }
    -
    - -
    - -
    - - -
    -
    - - {{ 'version-control.remove-other-entities' | translate }} - - - {{ 'version-control.find-existing-entity-by-name' | translate }} - -
    -
    - - {{ 'version-control.load-credentials' | translate }} - - - {{ 'version-control.load-attributes' | translate }} - - - {{ 'version-control.load-relations' | translate }} - - - {{ 'version-control.load-calculated-fields' | translate }} - + @for (entityTypeFormGroup of entityTypesFormGroupArray(); track entityTypeFormGroup; let index = $index, isLast = $last) { +
    + + +
    + +
    +
    {{ entityTypeText(entityTypeFormGroup) }}
    +
    +
    + + +
    +
    +
    + +
    + + +
    +
    + + {{ 'version-control.remove-other-entities' | translate }} + + + {{ 'version-control.find-existing-entity-by-name' | translate }} + +
    +
    + + {{ 'version-control.load-credentials' | translate }} + + + {{ 'version-control.load-attributes' | translate }} + + + {{ 'version-control.load-relations' | translate }} + + + {{ 'version-control.load-calculated-fields' | translate }} + +
    -
    - -
    -
    - version-control.no-entities-to-restore-prompt -
    + +
    + } @empty { + version-control.no-entities-to-restore-prompt + }
    -
    -
    -
    -
    +} @else { + @if ((versionCreateResult$ | async)?.done || resultMessage) { +
    {{ resultMessage }}
    -
    - + } @else {
    version-control.creating-version
    -
    -
    + } +} diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.html b/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.html index 1183223850..e4312e0c98 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.html @@ -15,51 +15,53 @@ limitations under the License. --> -
    - -

    {{ 'version-control.restore-entity-from-version' | translate: {versionName} }}

    - -
    - - - - -
    -
    - - {{ 'version-control.load-credentials' | translate }} - - - {{ 'version-control.load-attributes' | translate }} - - - {{ 'version-control.load-relations' | translate }} - - - {{ 'version-control.load-calculated-fields' | translate }} - -
    -
    - -
    - - -
    -
    -
    -
    -
    +@if (!versionLoadResult$) { + @if (entityDataInfo) { + +

    {{ 'version-control.restore-entity-from-version' | translate: {versionName} }}

    + +
    + + +
    +
    +
    + + {{ 'version-control.load-credentials' | translate }} + + + {{ 'version-control.load-attributes' | translate }} + + + {{ 'version-control.load-relations' | translate }} + + + {{ 'version-control.load-calculated-fields' | translate }} + +
    +
    +
    +
    + + +
    + } @else { + + } +} @else { + @if ((versionLoadResult$ | async)?.done || errorMessage) { +
    -
    - + } @else {
    version-control.restoring-entity-version
    -
    -
    + } +} diff --git a/ui-ngx/src/app/modules/home/components/vc/remove-other-entities-confirm.component.html b/ui-ngx/src/app/modules/home/components/vc/remove-other-entities-confirm.component.html index 71796247dc..6bd46bb9c8 100644 --- a/ui-ngx/src/app/modules/home/components/vc/remove-other-entities-confirm.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/remove-other-entities-confirm.component.html @@ -19,7 +19,7 @@
    - +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts index a9b9d207d0..01404f7c6e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts @@ -24,6 +24,7 @@ import { NgZone, OnDestroy, OnInit, + SecurityContext, ViewChild } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; @@ -53,6 +54,7 @@ import { import { deepClone } from '@core/utils'; import { hidePageSizePixelValue } from '@shared/models/constants'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; +import { DomSanitizer } from '@angular/platform-browser'; @Component({ selector: 'tb-manage-widget-actions', @@ -106,7 +108,8 @@ export class ManageWidgetActionsComponent extends PageComponent implements OnIni private dialogs: DialogService, private cd: ChangeDetectorRef, private elementRef: ElementRef, - private zone: NgZone) { + private zone: NgZone, + private sanitizer: DomSanitizer) { super(); const sortOrder: SortOrder = { property: 'actionSourceName', direction: Direction.ASC }; this.pageLink = new PageLink(10, 0, null, sortOrder); @@ -289,7 +292,8 @@ export class ManageWidgetActionsComponent extends PageComponent implements OnIni } const title = this.translate.instant('widget-config.delete-action-title'); const content = this.translate.instant('widget-config.delete-action-text', {actionName: action.name}); - this.dialogs.confirm(title, content, + const safeContent = this.sanitizer.sanitize(SecurityContext.HTML, content); + this.dialogs.confirm(title, safeContent, this.translate.instant('action.no'), this.translate.instant('action.yes'), true).subscribe( (res) => { 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 e5d006076e..48a1e17044 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 @@ -57,7 +57,7 @@ - {{ getCellClickColumnInfo($index, column) }} + {{ getCellClickColumnInfo($index, column) | customTranslate }}
    +
    + + {{ 'legend.show-total' | translate }} + +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.ts index 4a105c9cdd..7f86a5aebe 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.ts @@ -179,6 +179,7 @@ export abstract class LatestChartBasicConfigComponent
    +
    + + {{ 'tooltip.show-stack-total' | translate }} + +
    {{ 'tooltip.background-color' | translate }}
    this.paginator.pageIndex = 0); + + this.ctx.aliasController?.filtersChanged.pipe( + takeUntil(this.destroy$) + ).subscribe((filters) => { + let currentFilterId = this.ctx.defaultSubscription.options.alarmSource?.filterId; + if (currentFilterId && filters.includes(currentFilterId)) { + this.paginator.firstPage(); + } + }); } ((this.displayPagination ? merge(this.sort.sortChange, this.paginator.page) : this.sort.sortChange) as Observable).pipe( takeUntil(this.destroy$) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.scss index 7cbd58f5f7..fc1247c4f7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.scss @@ -50,6 +50,7 @@ $warning-color: #FAA405; color: $enabled-color; } .mat-mdc-progress-bar { + --mdc-linear-progress-track-color: #{rgba($enabled-color, 0.06)}; --mdc-linear-progress-active-indicator-color: #{$enabled-color}; } } @@ -58,6 +59,7 @@ $warning-color: #FAA405; color: $disabled-color; } .mat-mdc-progress-bar { + --mdc-linear-progress-track-color: #{rgba($disabled-color, 0.06)}; --mdc-linear-progress-active-indicator-color: #{$disabled-color}; } } @@ -66,6 +68,7 @@ $warning-color: #FAA405; color: $warning-color; } .mat-mdc-progress-bar { + --mdc-linear-progress-track-color: #{rgba($warning-color, 0.06)}; --mdc-linear-progress-active-indicator-color: #{$warning-color}; } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts index 1401104352..b7f48d41f6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts @@ -20,16 +20,16 @@ import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/wi import { Observable } from 'rxjs'; import { ImagePipe } from '@shared/pipe/image.pipe'; import { DomSanitizer } from '@angular/platform-browser'; -import { DataKey, DatasourceType, widgetType } from "@shared/models/widget.models"; -import { WidgetSubscriptionOptions } from "@core/api/widget-api.models"; -import { formattedDataFormDatasourceData } from "@core/utils"; +import { DatasourceType, widgetType } from '@shared/models/widget.models'; +import { WidgetSubscriptionOptions } from '@core/api/widget-api.models'; +import { formattedDataFormDatasourceData } from '@core/utils'; -import { UtilsService } from "@core/services/utils.service"; +import { UtilsService } from '@core/services/utils.service'; import { - ApiUsageDataKeysSettings, apiUsageDefaultSettings, - ApiUsageWidgetSettings -} from "@home/components/widget/lib/settings/cards/api-usage-settings.component.models"; + ApiUsageWidgetSettings, + getUniqueDataKeys +} from '@home/components/widget/lib/settings/cards/api-usage-settings.component.models'; @Component({ selector: 'tb-api-usage-widget', @@ -80,7 +80,7 @@ export class ApiUsageWidgetComponent implements OnInit, OnDestroy { type: DatasourceType.entity, name: '', entityAliasId: this.settings.dsEntityAliasId, - dataKeys: this.getUniqueDataKeys(this.settings.dataKeys) + dataKeys: getUniqueDataKeys(this.settings.apiUsageDataKeys) } const apiUsageSubscriptionOptions: WidgetSubscriptionOptions = { @@ -94,7 +94,7 @@ export class ApiUsageWidgetComponent implements OnInit, OnDestroy { const progress = data[0][key.maxLimit.key] !== 0 ? Math.min(100, ((data[0][key.current.key] / data[0][key.maxLimit.key]) * 100)) : 0; key.progress = isFinite(progress) ? progress : 0; key.status.value = data[0][key.status.key] ? data[0][key.status.key].toLowerCase() : 'enabled'; - key.maxLimit.value = isFinite(data[0][key.maxLimit.key]) && data[0][key.maxLimit.key] !== 0 ? this.toShortNumber(data[0][key.maxLimit.key]) : '∞'; + key.maxLimit.value = isFinite(data[0][key.maxLimit.key]) && data[0][key.maxLimit.key] !== 0 && data[0][key.maxLimit.key] !== '' ? this.toShortNumber(data[0][key.maxLimit.key]) : '∞'; key.current.value = isFinite(data[0][key.current.key]) ? this.toShortNumber(data[0][key.current.key]) : 0; }); this.cd.detectChanges(); @@ -122,7 +122,7 @@ export class ApiUsageWidgetComponent implements OnInit, OnDestroy { } parseApiUsages() { - this.settings.dataKeys.forEach((key) => { + this.settings.apiUsageDataKeys.forEach((key) => { this.apiUsages.push({ label: this.utils.customTranslation(key.label, key.label), state: key.state, @@ -134,20 +134,6 @@ export class ApiUsageWidgetComponent implements OnInit, OnDestroy { }) } - getUniqueDataKeys(data: ApiUsageDataKeysSettings[]): DataKey[] { - const seenNames = new Set(); - return data - .flatMap(item => [item.status, item.maxLimit, item.current]) - .filter(key => { - if (seenNames.has(key.name)) { - return false; - } - seenNames.add(key.name); - return true; - }); - }; - - ngOnDestroy() { if (this.contentResize$) { this.contentResize$.disconnect(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-widget.models.ts index 036ba70c04..3f76eb18cf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-widget.models.ts @@ -68,6 +68,7 @@ export const barChartWidgetBarsChartSettings = (settings: BarChartWidgetSettings showTotal: false, animation: settings.animation, showLegend: settings.showLegend, + legendShowTotal: settings.legendShowTotal, showTooltip: settings.showTooltip, tooltipValueType: settings.tooltipValueType, tooltipValueDecimals: settings.tooltipValueDecimals, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.models.ts index 0d8c1ba9fa..0ef277ea8b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.models.ts @@ -88,6 +88,7 @@ export const doughnutPieChartSettings = (settings: DoughnutWidgetSettings): Deep showTotal: settings.layout === DoughnutLayout.with_total, animation: settings.animation, showLegend: settings.showLegend, + legendShowTotal: settings.legendShowTotal, totalValueFont: settings.totalValueFont, totalValueColor: settings.totalValueColor, showLabel: false, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.scss index 2eb0ceb940..1c97102fe0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.scss @@ -51,7 +51,7 @@ flex-direction: row-reverse; } .tb-latest-chart-shape { - flex: 1; + flex: 1 1 100%; min-width: 10px; min-height: 10px; display: flex; @@ -95,8 +95,14 @@ &.legend-right, &.legend-left { gap: 24px; .tb-latest-chart-legend { + flex: 0 1 auto; + overflow-y: auto; + max-height: 100%; + flex-wrap: nowrap; + min-width: fit-content; + max-width: 100%; flex-direction: column; - justify-content: center; + justify-content: stretch; align-items: stretch; .tb-latest-chart-legend-item { flex-direction: row; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.ts index 85905dfd3e..1fa0437cea 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.ts @@ -181,17 +181,13 @@ export class LatestChartComponent implements OnInit, OnDestroy, AfterViewInit { private onResize() { if (this.legendHorizontal) { - this.renderer.setStyle(this.chartShape.nativeElement, 'max-width', null); this.renderer.setStyle(this.chartShape.nativeElement, 'min-width', null); - this.renderer.setStyle(this.chartLegend.nativeElement, 'flex', null); } const shapeWidth = this.chartShape.nativeElement.getBoundingClientRect().width; const shapeHeight = this.chartShape.nativeElement.getBoundingClientRect().height; const size = Math.min(shapeWidth, shapeHeight); if (this.legendHorizontal) { - this.renderer.setStyle(this.chartShape.nativeElement, 'max-width', `${size}px`); this.renderer.setStyle(this.chartShape.nativeElement, 'min-width', `${size}px`); - this.renderer.setStyle(this.chartLegend.nativeElement, 'flex', '1'); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts index 90928e6c93..aa1ac43644 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts @@ -87,6 +87,7 @@ export interface LatestChartSettings extends LatestChartTooltipSettings { sortSeries: boolean; showTotal?: boolean; showLegend: boolean; + legendShowTotal: boolean; animation: ChartAnimationSettings; } @@ -96,6 +97,7 @@ export const latestChartDefaultSettings: LatestChartSettings = { sortSeries: false, showTotal: false, showLegend: true, + legendShowTotal: true, animation: mergeDeep({} as ChartAnimationSettings, chartAnimationDefaultSettings) }; @@ -111,7 +113,6 @@ export interface LatestChartWidgetSettings extends LatestChartSettings { export const latestChartWidgetDefaultSettings: LatestChartWidgetSettings = { ...latestChartDefaultSettings, - showLegend: true, legendPosition: LegendPosition.bottom, legendLabelFont: { family: 'Roboto', diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.ts index b9f0940b52..9e525cd794 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.ts @@ -36,6 +36,7 @@ import { ValueFormatProcessor } from '@shared/models/widget-settings.models'; export abstract class TbLatestChart { private readonly shapeResize$: ResizeObserver; + private showTotalValueInLegend: boolean; protected readonly settings: S; @@ -121,7 +122,8 @@ export abstract class TbLatestChart { this.legendItems.sort((a, b) => a.label.localeCompare(b.label)); } } - if (this.settings.showLegend && !this.settings.showTotal) { + this.showTotalValueInLegend = this.settings.showLegend && !this.settings.showTotal && this.settings.legendShowTotal; + if (this.showTotalValueInLegend) { this.legendItems.push( { value: '--', @@ -252,11 +254,11 @@ export abstract class TbLatestChart { if (this.settings.showTotal || this.settings.showLegend) { if (hasValue) { this.totalText = this.valueFormatter.format(this.total); - if (this.settings.showLegend && !this.settings.showTotal) { + if (this.showTotalValueInLegend) { this.legendItems[this.legendItems.length - 1].hasValue = true; this.legendItems[this.legendItems.length - 1].value = this.totalText; } - } else if (this.settings.showLegend && !this.settings.showTotal) { + } else if (this.showTotalValueInLegend) { this.legendItems[this.legendItems.length - 1].hasValue = false; this.legendItems[this.legendItems.length - 1].value = '--'; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart-widget.models.ts index a98cdfdd4a..cddf75b9f9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart-widget.models.ts @@ -64,6 +64,7 @@ export const pieChartWidgetPieChartSettings = (settings: PieChartWidgetSettings) showTotal: false, animation: settings.animation, showLegend: settings.showLegend, + legendShowTotal: settings.legendShowTotal, showLabel: settings.showLabel, labelPosition: settings.labelPosition, labelFont: settings.labelFont, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/polar-area-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/polar-area-widget.models.ts index 70b4520159..4b744ce9c3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/polar-area-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/polar-area-widget.models.ts @@ -72,6 +72,7 @@ export const polarAreaChartWidgetBarsChartSettings = (settings: PolarAreaChartWi showTotal: false, animation: settings.animation, showLegend: settings.showLegend, + legendShowTotal: settings.legendShowTotal, showTooltip: settings.showTooltip, tooltipValueType: settings.tooltipValueType, tooltipValueDecimals: settings.tooltipValueDecimals, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.models.ts index ec42955b4d..b3be129489 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.models.ts @@ -135,6 +135,7 @@ export const radarChartWidgetRadarChartSettings = (settings: RadarChartWidgetSet showTotal: false, animation: settings.animation, showLegend: settings.showLegend, + legendShowTotal: settings.legendShowTotal, showTooltip: settings.showTooltip, tooltipValueType: settings.tooltipValueType, tooltipValueDecimals: settings.tooltipValueDecimals, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-tooltip.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-tooltip.models.ts index f8ead65e30..e7f3de8f31 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-tooltip.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-tooltip.models.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { isFunction } from '@core/utils'; +import { isDefined, isFunction, isNotEmptyStr } from '@core/utils'; import { FormattedData } from '@shared/models/widget.models'; import { DateFormatProcessor, DateFormatSettings, Font } from '@shared/models/widget-settings.models'; import { TimeSeriesChartDataItem } from '@home/components/widget/lib/chart/time-series-chart.models'; @@ -22,6 +22,7 @@ import { Renderer2, SecurityContext } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; import { CallbackDataParams } from 'echarts/types/dist/shared'; import { Interval } from '@shared/models/time/time.models'; +import { TranslateService } from '@ngx-translate/core'; export type TimeSeriesChartTooltipValueFormatFunction = (value: any, latestData: FormattedData, units?: string, decimals?: number) => string; @@ -43,6 +44,7 @@ export interface TimeSeriesChartTooltipWidgetSettings { tooltipDateColor: string; tooltipBackgroundColor: string; tooltipBackgroundBlur: number; + tooltipStackedShowTotal?: boolean } export enum TimeSeriesChartTooltipTrigger { @@ -88,7 +90,8 @@ export class TimeSeriesChartTooltip { private sanitizer: DomSanitizer, private settings: TimeSeriesChartTooltipWidgetSettings, private tooltipDateFormat: DateFormatProcessor, - private valueFormatFunction: TimeSeriesChartTooltipValueFormatFunction) { + private valueFormatFunction: TimeSeriesChartTooltipValueFormatFunction, + private translate: TranslateService) { } @@ -130,16 +133,65 @@ export class TimeSeriesChartTooltip { if (this.settings.tooltipShowDate) { this.renderer.appendChild(tooltipItemsElement, this.constructTooltipDateElement(items[0].param, interval)); } + let total = 0; + let isStacked = false; + const totalUnits = new Set(); + let totalDecimal = 0; for (const item of items) { - if (!this.settings.tooltipHideZeroValues || (item.param.value[1] && item.param.value[1] !== 'false')) { + if (this.shouldShowItem(item)) { this.renderer.appendChild(tooltipItemsElement, this.constructTooltipSeriesElement(item)); + if (item.dataItem?.option?.stack !== undefined && !isNaN(Number(item.param.value[1]))) { + isStacked = true; + total += Number(item.param.value[1]); + if (isNotEmptyStr(item.dataItem.units)) { + totalUnits.add(item.dataItem.units); + } + if (isDefined(item.dataItem.decimals)) { + totalDecimal = Math.max(item.dataItem.decimals, totalDecimal); + } + } } } + if (isStacked && this.settings.tooltipStackedShowTotal) { + const unit = totalUnits.size === 1 ? Array.from(totalUnits.values())[0] : ""; + const totalValue = this.valueFormatFunction(total, {} as FormattedData, unit, totalDecimal); + this.renderer.appendChild(tooltipItemsElement, this.constructTooltipTotalStackedElement(totalValue)); + } + } + } + + private shouldShowItem(item: TooltipItem): boolean { + if (!this.settings.tooltipHideZeroValues) return true; + const value = item.param?.value?.[1]; + return value && value !== 'false'; + } + + private createElement(tag = 'div', styles?: Record): HTMLElement { + const node = this.renderer.createElement(tag); + if (styles) { + for (const [k, v] of Object.entries(styles)) { + this.renderer.setStyle(node, k, v); + } + } + return node; + } + + private applyFont(el: HTMLElement, font: {family: string; size: number; sizeUnit: string; style: string; weight: string; lineHeight: string}, color: string, overrides?: Partial) { + this.renderer.setStyle(el, 'font-family', font.family); + this.renderer.setStyle(el, 'font-size', `${font.size}${font.sizeUnit}`); + this.renderer.setStyle(el, 'font-style', font.style); + this.renderer.setStyle(el, 'font-weight', font.weight); + this.renderer.setStyle(el, 'line-height', font.lineHeight); + this.renderer.setStyle(el, 'color', color); + if (overrides) { + for (const [k, v] of Object.entries(overrides)) { + if (v != null) this.renderer.setStyle(el, k, v as string); + } } } private constructTooltipDateElement(param: CallbackDataParams, interval?: Interval): HTMLElement { - const dateElement: HTMLElement = this.renderer.createElement('div'); + const dateElement = this.createElement(); let dateText: string; const startTs = param.value[2]; const endTs = param.value[3]; @@ -156,43 +208,25 @@ export class TimeSeriesChartTooltip { dateText = this.tooltipDateFormat.update(ts, interval); } this.renderer.appendChild(dateElement, this.renderer.createText(dateText)); - this.renderer.setStyle(dateElement, 'font-family', this.settings.tooltipDateFont.family); - this.renderer.setStyle(dateElement, 'font-size', this.settings.tooltipDateFont.size + this.settings.tooltipDateFont.sizeUnit); - this.renderer.setStyle(dateElement, 'font-style', this.settings.tooltipDateFont.style); - this.renderer.setStyle(dateElement, 'font-weight', this.settings.tooltipDateFont.weight); - this.renderer.setStyle(dateElement, 'line-height', this.settings.tooltipDateFont.lineHeight); - this.renderer.setStyle(dateElement, 'color', this.settings.tooltipDateColor); + this.applyFont(dateElement, this.settings.tooltipDateFont, this.settings.tooltipDateColor); return dateElement; } private constructTooltipSeriesElement(item: TooltipItem): HTMLElement { - const labelValueElement: HTMLElement = this.renderer.createElement('div'); - this.renderer.setStyle(labelValueElement, 'display', 'flex'); - this.renderer.setStyle(labelValueElement, 'flex-direction', 'row'); - this.renderer.setStyle(labelValueElement, 'align-items', 'center'); - this.renderer.setStyle(labelValueElement, 'align-self', 'stretch'); - this.renderer.setStyle(labelValueElement, 'gap', '12px'); - const labelElement: HTMLElement = this.renderer.createElement('div'); - this.renderer.setStyle(labelElement, 'display', 'flex'); - this.renderer.setStyle(labelElement, 'align-items', 'center'); - this.renderer.setStyle(labelElement, 'gap', '8px'); - this.renderer.appendChild(labelValueElement, labelElement); - const circleElement: HTMLElement = this.renderer.createElement('div'); - this.renderer.setStyle(circleElement, 'width', '8px'); - this.renderer.setStyle(circleElement, 'height', '8px'); - this.renderer.setStyle(circleElement, 'border-radius', '50%'); - this.renderer.setStyle(circleElement, 'background', item.param.color); - this.renderer.appendChild(labelElement, circleElement); - const labelTextElement: HTMLElement = this.renderer.createElement('div'); - this.renderer.setProperty(labelTextElement, 'innerHTML', this.sanitizer.sanitize(SecurityContext.HTML, item.param.seriesName)); - this.renderer.setStyle(labelTextElement, 'font-family', this.settings.tooltipLabelFont.family); - this.renderer.setStyle(labelTextElement, 'font-size', this.settings.tooltipLabelFont.size + this.settings.tooltipLabelFont.sizeUnit); - this.renderer.setStyle(labelTextElement, 'font-style', this.settings.tooltipLabelFont.style); - this.renderer.setStyle(labelTextElement, 'font-weight', this.settings.tooltipLabelFont.weight); - this.renderer.setStyle(labelTextElement, 'line-height', this.settings.tooltipLabelFont.lineHeight); - this.renderer.setStyle(labelTextElement, 'color', this.settings.tooltipLabelColor); - this.renderer.appendChild(labelElement, labelTextElement); - const valueElement: HTMLElement = this.renderer.createElement('div'); + const row = this.createElement('div', {display: 'flex', 'flex-direction': 'row', 'align-items': 'center', 'align-self': 'stretch', gap: '12px'}); + + const label = this.createElement('div', { display: 'flex', 'align-items': 'center', gap: '8px' }); + this.renderer.appendChild(row, label); + + const dot = this.createElement('div', { width: '8px', height: '8px', 'border-radius': '50%', background: item.param.color as string }); + this.renderer.appendChild(label, dot); + + const labelText = this.createElement('div'); + this.renderer.setProperty(labelText, 'innerHTML', this.sanitizer.sanitize(SecurityContext.HTML, item.param.seriesName)); + this.applyFont(labelText, this.settings.tooltipLabelFont, this.settings.tooltipLabelColor); + this.renderer.appendChild(label, labelText); + + const valueElement: HTMLElement = this.createElement('div', { flex: '1', 'text-align': 'end' }); let formatFunction = this.valueFormatFunction; let latestData: FormattedData; let units = ''; @@ -210,19 +244,32 @@ export class TimeSeriesChartTooltip { } const value = formatFunction(item.param.value[1], latestData, units, decimals); this.renderer.setProperty(valueElement, 'innerHTML', this.sanitizer.sanitize(SecurityContext.HTML, value)); - this.renderer.setStyle(valueElement, 'flex', '1'); - this.renderer.setStyle(valueElement, 'text-align', 'end'); - this.renderer.setStyle(valueElement, 'font-family', this.settings.tooltipValueFont.family); - this.renderer.setStyle(valueElement, 'font-size', this.settings.tooltipValueFont.size + this.settings.tooltipValueFont.sizeUnit); - this.renderer.setStyle(valueElement, 'font-style', this.settings.tooltipValueFont.style); - this.renderer.setStyle(valueElement, 'font-weight', this.settings.tooltipValueFont.weight); - this.renderer.setStyle(valueElement, 'line-height', this.settings.tooltipValueFont.lineHeight); - this.renderer.setStyle(valueElement, 'color', this.settings.tooltipValueColor); - this.renderer.appendChild(labelValueElement, valueElement); - return labelValueElement; + this.applyFont(valueElement, this.settings.tooltipValueFont, this.settings.tooltipValueColor); + this.renderer.appendChild(row, valueElement); + + return row; + } + + private constructTooltipTotalStackedElement(total: string): HTMLElement { + const row = this.createElement('div', {display: 'flex', 'flex-direction': 'row', 'align-items': 'center', 'align-self': 'stretch', gap: '12px'}); + + const label = this.createElement('div', { display: 'flex', 'align-items': 'center', gap: '8px' }); + this.renderer.appendChild(row, label); + + const labelText = this.createElement('div'); + this.renderer.setProperty(labelText, 'innerHTML', this.sanitizer.sanitize(SecurityContext.HTML, this.translate.instant('legend.Total'))); + this.applyFont(labelText, this.settings.tooltipLabelFont, this.settings.tooltipLabelColor, { fontWeight: 'bold' }); + this.renderer.appendChild(label, labelText); + + const valueEl = this.createElement('div', { flex: '1', 'text-align': 'end' }); + this.renderer.setProperty(valueEl, 'innerHTML', this.sanitizer.sanitize(SecurityContext.HTML, total)); + this.applyFont(valueEl, this.settings.tooltipValueFont, this.settings.tooltipValueColor, { fontWeight: 'bold' }); + this.renderer.appendChild(row, valueEl); + + return row; } - private static mapTooltipParams(params: CallbackDataParams[] | CallbackDataParams, +private static mapTooltipParams(params: CallbackDataParams[] | CallbackDataParams, series?: TimeSeriesChartDataItem[], focusedSeriesIndex?: number): TooltipParams { const result: TooltipParams = { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts index 86d952f2e2..089c655224 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts @@ -143,7 +143,7 @@ export interface TimeSeriesChartDataItem { xAxisIndex: number; yAxisId: TimeSeriesChartYAxisId; yAxisIndex: number; - option?: LineSeriesOption | CustomSeriesOption; + option?: LineSeriesOption; barRenderContext?: BarRenderContext; unitConvertor?: TbUnitConverter; } @@ -762,6 +762,7 @@ export const timeSeriesChartDefaultSettings: TimeSeriesChartSettings = { }, tooltipDateColor: 'rgba(0, 0, 0, 0.76)', tooltipDateInterval: true, + tooltipStackedShowTotal: false, tooltipBackgroundColor: 'rgba(255, 255, 255, 0.76)', tooltipBackgroundBlur: 4, comparisonEnabled: false, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts index 7ac6e3ec85..d447b51b37 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts @@ -212,7 +212,8 @@ export class TbTimeSeriesChart { this.ctx.sanitizer, this.settings, this.tooltipDateFormat, - tooltipValueFormatFunction + tooltipValueFormatFunction, + this.ctx.translate ); this.onResize(); if (this.autoResize) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.html index 65d647993f..d1142d08ed 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.html @@ -17,8 +17,16 @@ -->
    - - {{ column.title }} + + {{'entity.show-all-columns' | translate}} + + @for (column of columns; track column.def) { + + {{ column.title }} + + }
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts index 8c5f13de69..4ef00bd575 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts @@ -15,6 +15,8 @@ /// import { Component, Inject, InjectionToken } from '@angular/core'; +import { isDefinedAndNotNull } from '@core/utils'; +import { SelectableColumnsPipe } from '@shared/public-api'; import { DisplayColumn } from '@home/components/widget/lib/table-widget.models'; export const DISPLAY_COLUMNS_PANEL_DATA = new InjectionToken('DisplayColumnsPanelData'); @@ -33,8 +35,28 @@ export class DisplayColumnsPanelComponent { columns: DisplayColumn[]; - constructor(@Inject(DISPLAY_COLUMNS_PANEL_DATA) public data: DisplayColumnsPanelData) { - this.columns = this.data.columns; + constructor(@Inject(DISPLAY_COLUMNS_PANEL_DATA) public data: DisplayColumnsPanelData, + private selectableColumnsPipe: SelectableColumnsPipe ) { + this.columns = this.selectableColumnsPipe.transform(this.data.columns); + } + + get allColumnsVisible(): boolean { + return isDefinedAndNotNull(this.columns) && this.columns.every(column => column.display); + } + + get someColumnsVisible(): boolean { + const filtredColumns = this.columns.filter(item => item.display); + return filtredColumns.length !== 0 && this.columns.length !== filtredColumns.length; + } + + public toggleAllColumns(event: any): void { + const isChecked = event.checked; + + this.columns.forEach(column => { + column.display = isChecked; + }); + + this.update(); } public update() { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts index f95c8c26d7..87acdd686b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts @@ -265,6 +265,15 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni if (this.displayPagination) { this.sort.sortChange.pipe(takeUntil(this.destroy$)).subscribe(() => this.paginator.pageIndex = 0); + + this.ctx.aliasController?.filtersChanged.pipe( + takeUntil(this.destroy$) + ).subscribe((filters) => { + let currentFilterId = this.ctx.defaultSubscription.options.datasources?.[0]?.filterId; + if (currentFilterId && filters.includes(currentFilterId)) { + this.paginator.firstPage(); + } + }); } ((this.displayPagination ? merge(this.sort.sortChange, this.paginator.page) : this.sort.sortChange) as Observable).pipe( takeUntil(this.destroy$) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts index e3a23673f7..b0ffeebf5b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts @@ -510,11 +510,9 @@ export class LiquidLevelWidgetComponent implements OnInit { let content: string; let container: JQuery; const jQueryContainerElement = $(this.liquidLevelContent.nativeElement); - let value = 'N/A'; - + let value: number | string = 'N/A'; if (isNumeric(data)) { - value = this.widgetUnitsConvertor(convertLiters(this.convertOutputData(percentage), this.widgetUnits as CapacityUnits, ConversionType.from)) - .toFixed(this.settings.decimals || 0); + value = +this.widgetUnitsConvertor(convertLiters(this.convertOutputData(percentage), this.widgetUnits as CapacityUnits, ConversionType.from)).toFixed(this.ctx.widgetConfig.decimals || 0); } this.valueColor.update(value); const valueTextStyle = cssTextFromInlineStyle({...inlineTextStyle(this.settings.valueFont), @@ -528,10 +526,9 @@ export class LiquidLevelWidgetComponent implements OnInit { let volume: number | string; if (this.widgetUnits !== CapacityUnits.percent) { const volumeInLiters: number = convertLiters(this.volume, this.volumeUnits as CapacityUnits, ConversionType.to); - volume = this.widgetUnitsConvertor(convertLiters(volumeInLiters, this.widgetUnits as CapacityUnits, ConversionType.from)) - .toFixed(this.settings.decimals || 0); + volume = +this.widgetUnitsConvertor(convertLiters(volumeInLiters, this.widgetUnits as CapacityUnits, ConversionType.from)).toFixed(this.ctx.widgetConfig.decimals || 0); } else { - volume = this.widgetUnitsConvertor(this.volume).toFixed(this.settings.decimals || 0); + volume = +this.widgetUnitsConvertor(this.volume).toFixed(this.ctx.widgetConfig.decimals || 0); } const volumeTextStyle = cssTextFromInlineStyle({...inlineTextStyle(this.settings.volumeFont), diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polylines-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polylines-data-layer.ts new file mode 100644 index 0000000000..1bb4b97b4c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polylines-data-layer.ts @@ -0,0 +1,466 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + defaultBasePolylinesDataLayerSettings, + isCutPolygon, + isJSON, + MapDataLayerType, + PolylinesDataLayerSettings, + TbMapDatasource, + TbPolylineCoordinates, + TbPolylineData, + TbPolylineRawCoordinates +} from '@shared/models/widget/maps/map.models'; +import L from 'leaflet'; +import { DataKey, FormattedData } from '@shared/models/widget.models'; +import { ShapeStyleInfo, TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { Observable } from 'rxjs'; +import { isNotEmptyStr, isString } from '@core/utils'; +import { + TbLatestDataLayerItem, + UnplacedMapDataItem +} from '@home/components/widget/lib/maps/data-layer/latest-map-data-layer'; +import { map } from 'rxjs/operators'; + +class TbPolylineDataLayerItem extends TbLatestDataLayerItem { + + private polylineContainer: L.FeatureGroup; + private polyline: L.Polyline; + private polylineStyleInfo: ShapeStyleInfo; + private editing = false; + + constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: PolylinesDataLayerSettings, + protected dataLayer: TbPolylineDataLayer) { + super(data, dsData, settings, dataLayer); + } + + public isEditing() { + return this.editing; + } + + public updateBubblingMouseEvents() { + this.polyline.options.bubblingMouseEvents = !this.dataLayer.isEditMode(); + } + + public remove() { + super.remove(); + if (this.polylineStyleInfo?.patternId) { + this.dataLayer.getMap().unUseShapePattern(this.polylineStyleInfo.patternId); + } + } + + protected create(data: FormattedData, dsData: FormattedData[]): L.Layer { + const polyData = this.dataLayer.extractPolylineCoordinates(data); + const polyConstructor = L.polyline; + this.polyline = polyConstructor(polyData as (TbPolylineRawCoordinates & L.LatLngTuple[]), { + noClip: true, + snapIgnore: !this.dataLayer.isSnappable(), + bubblingMouseEvents: !this.dataLayer.isEditMode() + }); + + this.dataLayer.getStrokeStyle(data, dsData, this.polylineStyleInfo?.patternId).subscribe((styleInfo) => { + this.polylineStyleInfo = styleInfo; + if (this.polyline) { + this.polyline.setStyle(this.polylineStyleInfo.style); + } + }); + + this.polylineContainer = L.featureGroup(); + this.polyline.addTo(this.polylineContainer); + + this.updateLabel(data, dsData); + return this.polylineContainer; + } + + protected unbindLabel() { + this.polylineContainer.unbindTooltip(); + } + + protected bindLabel(content: L.Content): void { + this.polylineContainer.bindTooltip(content, {className: 'tb-polyline-label', permanent: true, direction: 'center'}) + .openTooltip(this.polylineContainer.getBounds().getCenter()); + } + + protected doUpdate(data: FormattedData, dsData: FormattedData[]): void { + this.dataLayer.getStrokeStyle(data, dsData, this.polylineStyleInfo?.patternId).subscribe((styleInfo) => { + this.polylineStyleInfo = styleInfo; + this.updatePolylineShape(data); + this.updateTooltip(data, dsData); + this.updateLabel(data, dsData); + + if (!this.editing || !this.dataLayer.getMap().getMap().pm.globalCutModeEnabled()) { + this.polyline.setStyle(this.polylineStyleInfo.style); + } + }); + } + + protected doInvalidateCoordinates(data: FormattedData, _dsData: FormattedData[]): void { + this.updatePolylineShape(data); + } + + protected addItemClass(clazz: string): void { + if ((this.polyline as any)._path) { + L.DomUtil.addClass((this.polyline as any)._path, clazz); + } + } + + protected removeItemClass(clazz: string): void { + if ((this.polyline as any)._path) { + L.DomUtil.removeClass((this.polyline as any)._path, clazz); + } + } + + protected enableDrag(): void { + this.polyline.pm.setOptions({ + snappable: this.dataLayer.isSnappable() + }); + this.polyline.pm.enableLayerDrag(); + this.polyline.on('pm:dragstart', () => { + this.editing = true; + }); + this.polyline.on('pm:drag', () => { + if (this.tooltip?.isOpen()) { + this.tooltip.setLatLng(this.polyline.getBounds().getCenter()); + } + }); + this.polyline.on('pm:dragend', () => { + this.savePolylineCoordinates(); + this.editing = false; + }); + } + + protected disableDrag(): void { + this.polyline.pm.disableLayerDrag(); + this.polyline.off('pm:dragstart'); + this.polyline.off('pm:dragend'); + } + + protected onSelected(): L.TB.ToolbarButtonOptions[] { + const buttons: L.TB.ToolbarButtonOptions[] = []; + if (this.dataLayer.isEditEnabled()) { + this.enablePolylineEditMode(); + buttons.push( + { + id: 'cut', + title: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polygon.cut'), + iconClass: 'tb-cut', + click: (e, button) => { + const map = this.dataLayer.getMap().getMap(); + if (!map.pm.globalCutModeEnabled()) { + this.disablePolylineRotateMode(); + this.disablePolylineEditMode(); + this.enablePolylineCutMode(button); + } else { + this.disablePolylineCutMode(button); + this.enablePolylineEditMode(); + } + } + }, + { + id: 'rotate', + title: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polyline.rotate'), + iconClass: 'tb-rotate', + click: (e, button) => { + if (!this.polyline.pm.rotateEnabled()) { + this.disablePolylineCutMode(); + this.disablePolylineEditMode(); + this.enablePolylineRotateMode(button); + } else { + this.disablePolylineRotateMode(button); + this.enablePolylineEditMode(); + } + } + } + ); + } + return buttons; + } + + protected onDeselected(): void { + if (this.dataLayer.isEditEnabled()) { + this.disablePolylineEditMode(); + this.disablePolylineRotateMode(); + } + } + + protected canDeselect(cancel = false): boolean { + const map = this.dataLayer.getMap().getMap(); + if (map.pm.globalCutModeEnabled()) { + if (cancel) { + this.disablePolylineCutMode(); + } + return false; + } else if (this.polyline.pm.rotateEnabled()) { + if (cancel) { + this.disablePolylineRotateMode(); + } + return false; + } else if (this.editing) { + return false; + } + return true; + } + + protected removeDataItemTitle(): string { + return this.dataLayer.getCtx().translate.instant('widgets.maps.data-layer.polyline.remove-polyline-for', {entityName: this.data.entityName}); + } + + protected removeDataItem(): Observable { + return this.dataLayer.savePolylineCoordinates(this.data, null); + } + + private enablePolylineEditMode() { + this.polyline.on('pm:markerdragstart', () => this.editing = true); + this.polyline.on('pm:markerdragend', () => setTimeout(() => { + this.editing = false; + })); + this.polyline.on('pm:edit', () => this.savePolylineCoordinates()); + this.polyline.pm.enable(); + const map = this.dataLayer.getMap(); + map.getEditToolbar().getButton('remove')?.setDisabled(false); + } + + private disablePolylineEditMode() { + this.polyline.pm.disable(); + this.polyline.off('pm:markerdragstart'); + this.polyline.off('pm:markerdragend'); + this.polyline.off('pm:edit'); + const map = this.dataLayer.getMap(); + map.getEditToolbar().getButton('remove')?.setDisabled(true); + } + + private enablePolylineCutMode(cutButton?: L.TB.ToolbarButton) { + this.polylineContainer.closePopup(); + this.editing = true; + this.polyline.options.bubblingMouseEvents = true; + this.polyline.setStyle({ + ...this.polylineStyleInfo.style, dashArray: '5 5', weight: 3, + color: '#3388ff', opacity: 1, fillColor: '#3388ff', fillOpacity: 0.2 + }); + this.addItemClass('tb-cut-mode'); + this.polyline.once('pm:cut', (e) => { + if (e.layer instanceof L.Polyline) { + this.polyline = L.polyline(e.layer.getLatLngs() as L.LatLngExpression[] | L.LatLngExpression[][] , { + ...this.polylineStyleInfo.style, + snapIgnore: !this.dataLayer.isSnappable(), + bubblingMouseEvents: !this.dataLayer.isEditMode() + }); + this.polyline.addTo(this.polylineContainer); + } else if (e.layer instanceof L.LayerGroup) { + const parts: L.LatLngExpression[][] = []; + + if (e.layer && typeof e.layer.getLayers === 'function') { + const segments: L.Polyline[] = e.layer.getLayers() as L.Polyline[]; + segments.forEach(segment => { + parts.push(segment.getLatLngs() as L.LatLngExpression[]); + }); + } else if (e.layer instanceof L.Polyline) { + parts.push(e.layer.getLatLngs() as L.LatLngExpression[]); + } + + if (parts.length > 0) { + this.polyline = L.polyline(parts, { + ...this.polylineStyleInfo.style, + snapIgnore: !this.dataLayer.isSnappable(), + bubblingMouseEvents: !this.dataLayer.isEditMode() + }); + this.polyline.addTo(this.polylineContainer); + } + } + // @ts-ignore + e.layer._pmTempLayer = true; + e.layer.remove(); + this.polylineContainer.removeLayer(this.polyline); + // @ts-ignore + this.polyline._pmTempLayer = false; + this.polyline.addTo(this.polylineContainer); + this.updateSelectedState(); + cutButton?.setActive(false); + this.savePolylineCoordinates(); + }); + const map = this.dataLayer.getMap().getMap(); + map.pm.setLang('en', { + tooltips: { + firstVertex: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polyline.polyline-place-first-point-cut-hint'), + finishLine: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polyline.finish-polyline-cut-hint') + } + }, 'en'); + map.pm.enableGlobalCutMode({ + // @ts-ignore + layersToCut: [this.polyline] + }); + // @ts-ignore + L.DomUtil.addClass(map.pm.Draw.Cut._hintMarker.getTooltip()._container, 'tb-place-item-label'); + cutButton?.setActive(true); + map.once('pm:globalcutmodetoggled', (e) => { + if (!e.enabled) { + this.disablePolylineCutMode(cutButton); + this.enablePolylineEditMode(); + } + }); + } + + private disablePolylineCutMode(cutButton?: L.TB.ToolbarButton) { + this.editing = false; + this.polyline.options.bubblingMouseEvents = !this.dataLayer.isEditMode(); + this.polyline.setStyle({...this.polylineStyleInfo.style, dashArray: null}); + this.removeItemClass('tb-cut-mode'); + this.polyline.off('pm:cut'); + const map = this.dataLayer.getMap().getMap(); + map.pm.disableGlobalCutMode(); + cutButton?.setActive(false); + } + + private enablePolylineRotateMode(rotateButton?: L.TB.ToolbarButton) { + this.polylineContainer.closePopup(); + this.editing = true; + this.polyline.on('pm:rotateend', () => { + this.savePolylineCoordinates(); + }); + this.polyline.pm.enableRotate(); + rotateButton?.setActive(true); + this.polyline.on('pm:rotatedisable', () => { + this.disablePolylineRotateMode(rotateButton); + this.enablePolylineEditMode(); + }); + } + + private disablePolylineRotateMode(rotateButton?: L.TB.ToolbarButton) { + this.editing = false; + this.polyline.pm.disableRotate(); + this.polyline.off('pm:rotateend'); + this.polyline.off('pm:rotatedisable'); + rotateButton?.setActive(false); + } + + private savePolylineCoordinates() { + let coordinates: TbPolylineCoordinates = this.polyline.getLatLngs(); + if (coordinates.length === 1) { + coordinates = coordinates[0] as TbPolylineCoordinates; + } + if (this.polyline instanceof L.Rectangle && !isCutPolygon(coordinates)) { + const bounds = this.polyline.getBounds(); + const boundsArray = [bounds.getNorthWest(), bounds.getNorthEast(), bounds.getSouthWest(), bounds.getSouthEast()]; + if (coordinates.every(point => boundsArray.find(boundPoint => boundPoint.equals(point as L.LatLng)) !== undefined)) { + coordinates = [bounds.getNorthWest(), bounds.getSouthEast()]; + } + } + this.dataLayer.savePolylineCoordinates(this.data, coordinates).subscribe(); + } + + private updatePolylineShape(data: FormattedData) { + if (this.editing) { + return; + } + + const polyData = this.dataLayer.extractPolylineCoordinates(data) as TbPolylineData; + if (this.polyline instanceof L.Polyline && !(this.polyline instanceof L.Rectangle)) { + this.polyline.setLatLngs(polyData as L.LatLngExpression[] | L.LatLngExpression[][]); + } else if (this.polyline instanceof L.Rectangle && polyData.length === 2) { + const bounds = new L.LatLngBounds(polyData as L.LatLngTuple[]); + this.polyline.setBounds(bounds); + } else { + this.polylineContainer.removeLayer(this.polyline); + if (polyData.length === 2 && isCutPolygon(polyData)) { + this.polyline = L.polyline(polyData as L.LatLngExpression[] | L.LatLngExpression[][], { + ...this.polylineStyleInfo.style, + snapIgnore: !this.dataLayer.isSnappable(), + bubblingMouseEvents: !this.dataLayer.isEditMode(), + noClip: true + }); + } + + this.polyline.addTo(this.polylineContainer); + this.editModeUpdated(); + } + } +} + +export class TbPolylineDataLayer extends TbShapesDataLayer { + + constructor(protected map: TbMap, + inputSettings: PolylinesDataLayerSettings) { + super(map, inputSettings); + } + + public dataLayerType(): MapDataLayerType { + return 'polylines'; + } + + public placeItem(item: UnplacedMapDataItem, layer: L.Layer): void { + if (layer instanceof L.Polyline) { + let coordinates: TbPolylineCoordinates; + coordinates = layer.getLatLngs(); + if (coordinates.length === 1) { + coordinates = coordinates[0] as TbPolylineCoordinates; + } + this.savePolylineCoordinates(item.entity, coordinates).subscribe( + (converted) => { + item.entity[this.settings.polylineKey.label] = JSON.stringify(converted); + this.createItemFromUnplaced(item); + } + ); + } else { + console.warn('Unable to place item, layer is not a polyline.'); + } + } + + public extractPolylineCoordinates(data: FormattedData): TbPolylineRawCoordinates { + let rawPolyData = data[this.settings.polylineKey.label]; + if (isString(rawPolyData)) { + rawPolyData = JSON.parse(rawPolyData); + } + return this.map.polylineDataToCoordinates(rawPolyData); + } + + public savePolylineCoordinates(data: FormattedData, coordinates: TbPolylineCoordinates): Observable { + const converted = coordinates ? this.map.coordinatesToPolylineData(coordinates) : null; + const polylineData = [ + { + dataKey: this.settings.polylineKey, + value: converted + } + ]; + return this.map.saveItemData(data.$datasource, polylineData, this.settings.edit?.attributeScope).pipe( + map(() => converted) + ); + } + + protected getDataKeys(): DataKey[] { + return [this.settings.polylineKey]; + } + + protected defaultBaseSettings(map: TbMap): Partial { + return defaultBasePolylinesDataLayerSettings(map.type()); + } + + protected doSetup(): Observable { + return super.doSetup(); + } + + protected isValidLayerData(layerData: FormattedData): boolean { + return layerData && ((isNotEmptyStr(layerData[this.settings.polylineKey.label]) && !isJSON(layerData[this.settings.polylineKey.label]) + || Array.isArray(layerData[this.settings.polylineKey.label]))); + } + + protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbPolylineDataLayerItem { + return new TbPolylineDataLayerItem(data, dsData, this.settings, this); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts index 93502588b1..ef68f0a6b3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts @@ -37,10 +37,12 @@ import { isDefinedAndNotNull, objectHashCode, parseTbFunction, safeExecuteTbFunc import { CompiledTbFunction } from '@shared/models/js-function.models'; import { ImagePipe } from '@shared/pipe/image.pipe'; -export type ShapePatternStorage = {[id: string]: { - pattern: L.TB.Pattern; - refCount: number; -}}; +export type ShapePatternStorage = { + [id: string]: { + pattern: L.TB.Pattern; + refCount: number; + } +}; interface ShapePatternInfo { type: ShapeFillType; @@ -311,6 +313,23 @@ export abstract class TbShapesDataLayer, dsData: FormattedData[], fillPatternId: string): Observable { + return this.shapePatternProcessor.processPattern(data, dsData, fillPatternId).pipe( + map((patternWithId) => { + const stroke = this.strokeColorProcessor.processColor(data, dsData); + const style: L.PathOptions = { + color: stroke, + weight: this.settings.strokeWeight, + opacity: 1 + }; + return { + patternId: patternWithId.patternId, + style + } + }) + ); + } + public getShapeStyle(data: FormattedData, dsData: FormattedData[], fillPatternId: string): Observable { return this.shapePatternProcessor.processPattern(data, dsData, fillPatternId).pipe( map((patternWithId) => { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts index ae2f62853e..bfb872c75a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts @@ -44,7 +44,7 @@ import { MarkerDataProcessor } from '@home/components/widget/lib/maps/data-layer type TripRouteData = {[time: number]: FormattedData}; -interface PointItem { +export interface PointItem { point: L.CircleMarker; tooltip?: L.Popup; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts index fd03990d26..6093b8ee8e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts @@ -24,7 +24,11 @@ import { TbPolygonCoordinate, TbPolygonCoordinates, TbPolygonRawCoordinate, - TbPolygonRawCoordinates + TbPolygonRawCoordinates, + TbPolylineCoordinate, + TbPolylineCoordinates, + TbPolylineRawCoordinate, + TbPolylineRawCoordinates } from '@shared/models/widget/maps/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { DeepPartial } from '@shared/models/common'; @@ -179,5 +183,29 @@ export class TbGeoMap extends TbMap { return circleData; } + public polylineDataToCoordinates(expression: TbPolylineRawCoordinates): TbPolylineRawCoordinates { + return (expression).map((el: TbPolylineRawCoordinate) => { + if (!Array.isArray(el[0]) && !Array.isArray(el[1]) && el.length === 2) { + return el; + } else if (Array.isArray(el) && el.length) { + return this.polylineDataToCoordinates(el as TbPolylineRawCoordinates) as TbPolylineRawCoordinate; + } else { + return null; + } + }).filter(el => !!el); + } + public coordinatesToPolylineData(coordinates: TbPolylineCoordinates): TbPolylineRawCoordinates { + if (coordinates.length) { + return coordinates.map((point: TbPolylineCoordinate) => { + if (Array.isArray(point)) { + return this.coordinatesToPolylineData(point) as TbPolylineRawCoordinate; + } else { + const convertPoint = latLngPointToBounds(point, this.southWest, this.northEast); + return [convertPoint.lat, convertPoint.lng]; + } + }); + } + return []; + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts index 219f5d63ff..753528f006 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts @@ -23,7 +23,12 @@ import { ImageSourceType, loadImageWithAspect, MapZoomAction, - TbCircleData, TbPolygonCoordinate, TbPolygonCoordinates, TbPolygonRawCoordinate, TbPolygonRawCoordinates + TbCircleData, + TbPolygonCoordinate, + TbPolygonCoordinates, + TbPolygonRawCoordinate, + TbPolygonRawCoordinates, TbPolylineCoordinate, TbPolylineCoordinates, TbPolylineRawCoordinate, + TbPolylineRawCoordinates } from '@shared/models/widget/maps/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { DeepPartial } from '@shared/models/common'; @@ -339,4 +344,36 @@ export class TbImageMap extends TbMap { ); } + public polylineDataToCoordinates(expression: TbPolylineRawCoordinates): TbPolylineRawCoordinates{ + return expression.map((el: TbPolylineRawCoordinate) => { + if (!Array.isArray(el[0]) && !Array.isArray(el[1]) && el.length === 2) { + const latLng = this.pointToLatLng( + el[0] * this.width, + el[1] * this.height + ); + return [latLng.lat, latLng.lng] as TbPolylineRawCoordinate; + } else if (Array.isArray(el) && el.length) { + return this.polylineDataToCoordinates(el as TbPolylineRawCoordinates) as TbPolylineRawCoordinate; + } + else { + return null; + } + }).filter(el => !!el); + } + + public coordinatesToPolylineData(coordinates: TbPolylineCoordinates): TbPolylineRawCoordinates{ + if (coordinates.length) { + return coordinates.map((point: TbPolylineCoordinate) => { + if (Array.isArray(point)) { + return this.coordinatesToPolylineData(point) as TbPolylineRawCoordinate; + } else { + const pos = this.latLngToPoint(point); + return [calculateNewPointCoordinate(pos.x, this.width), calculateNewPointCoordinate(pos.y, this.height)]; + } + }); + } else { + return []; + } + } + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index 985bdc17f5..5d00f9add6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -169,6 +169,9 @@ div.tb-widget .tb-widget-content.tb-no-interaction { &.tb-draw-circle { mask-image: url('data:image/svg+xml,'); } + &.tb-draw-polyline { + mask-image: url('data:image/svg+xml,'); + } &.tb-close { background: #D12730; mask-image: url('data:image/svg+xml,'); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index aa8dd67494..2c111110c9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -27,7 +27,9 @@ import { TbCircleData, TbMapDatasource, TbPolygonCoordinates, - TbPolygonRawCoordinates + TbPolygonRawCoordinates, + TbPolylineCoordinates, + TbPolylineRawCoordinates } from '@shared/models/widget/maps/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { @@ -80,11 +82,12 @@ import { TbTripsDataLayer } from '@home/components/widget/lib/maps/data-layer/tr import { CompiledTbFunction } from '@shared/models/js-function.models'; import { TbMapDataLayer } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; import { EntityType } from '@shared/models/entity-type.models'; +import { ShapePatternStorage } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer'; +import { TbPolylineDataLayer } from '@home/components/widget/lib/maps/data-layer/polylines-data-layer'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; -import { ShapePatternStorage } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer'; -type TooltipInstancesData = {root: HTMLElement, instances: ITooltipsterInstance[]}; +type TooltipInstancesData = { root: HTMLElement, instances: ITooltipsterInstance[] }; export abstract class TbMap { @@ -124,6 +127,7 @@ export abstract class TbMap { protected addRectangleButton: L.TB.ToolbarButton; protected addPolygonButton: L.TB.ToolbarButton; protected addCircleButton: L.TB.ToolbarButton; + protected addPolylineButton: L.TB.ToolbarButton; protected timeLineComponentRef: ComponentRef; protected timeLineComponent: MapTimelinePanelComponent; @@ -132,6 +136,7 @@ export abstract class TbMap { protected addMarkerDataLayers: TbLatestMapDataLayer[]; protected addPolygonDataLayers: TbLatestMapDataLayer[]; protected addCircleDataLayers: TbLatestMapDataLayer[]; + protected addPolylineDataLayers: TbLatestMapDataLayer[]; protected shapePatternStorage: ShapePatternStorage = {}; @@ -274,6 +279,11 @@ export abstract class TbMap { this.dataLayers.push(...circlesDataLayers); this.latestDataLayers.push(...circlesDataLayers); } + if (this.settings.polylines) { + const polylinesDataLayers = this.settings.polylines.map(settings => new TbPolylineDataLayer(this, settings)); + this.dataLayers.push(...polylinesDataLayers); + this.latestDataLayers.push(...polylinesDataLayers); + } if (this.settings.trips) { const tripsDataLayers = this.settings.trips.map(settings => new TbTripsDataLayer(this, settings)); this.dataLayers.push(...tripsDataLayers); @@ -319,7 +329,7 @@ export abstract class TbMap { let datasources: TbMapDatasource[]; for (const layerType of mapDataLayerTypes) { const typeDatasources = this.latestDataLayers.filter(dl => dl.dataLayerType() === layerType) - .map(dl => dl.getDataSources()).flat(); + .map(dl => dl.getDataSources()).flat(); if (!datasources) { datasources = typeDatasources; } else { @@ -419,88 +429,100 @@ export abstract class TbMap { private setupEditMode() { - this.editToolbar = L.TB.bottomToolbar({ - mapElement: $(this.mapElement), - closeTitle: this.ctx.translate.instant('action.cancel'), - onClose: () => { - return this.deselectItem(true); - } - }); - - this.map.on('click', () => { - this.deselectItem(); - }); - - if (this.latestDataLayers.some(dl => dl.isEditable())) { - this.map.pm.setGlobalOptions({ snappable: false }); - this.map.pm.applyGlobalOptions(); - } - - const dragSupportedDataLayers = this.latestDataLayers.filter(dl => dl.isDragEnabled()); - const showDragModeButton = this.settings.dragModeButton && dragSupportedDataLayers.length; - const addSupportedDataLayers = this.latestDataLayers.filter(dl => dl.isAddEnabled()); - - if (showDragModeButton || addSupportedDataLayers.length) { - const drawToolbar = L.TB.toolbar({ - position: this.settings.controlsPosition - }).addTo(this.map); - if (showDragModeButton) { - this.dragModeButton = drawToolbar.toolbarButton({ - id: 'dragMode', - title: this.ctx.translate.instant('widgets.maps.data-layer.drag-drop-mode'), - iconClass: 'tb-drag-mode', - click: (e, button) => { - this.toggleDragMode(e, button); - } - }); - } - this.addMarkerDataLayers = addSupportedDataLayers.filter(dl => dl.dataLayerType() === 'markers'); - if (this.addMarkerDataLayers.length) { - this.addMarkerButton = drawToolbar.toolbarButton({ - id: 'addMarker', - title: this.ctx.translate.instant('widgets.maps.data-layer.marker.place-marker'), - iconClass: 'tb-place-marker', - click: (e, button) => { - this.placeMarker(e, button); - } - }); - this.addMarkerButton.setDisabled(true); - this.setPlaceMarkerStyle(); - } - this.addPolygonDataLayers = addSupportedDataLayers.filter(dl => dl.dataLayerType() === 'polygons'); - if (this.addPolygonDataLayers.length) { - this.addRectangleButton = drawToolbar.toolbarButton({ - id: 'addRectangle', - title: this.ctx.translate.instant('widgets.maps.data-layer.polygon.draw-rectangle'), - iconClass: 'tb-draw-rectangle', - click: (e, button) => { - this.drawRectangle(e, button); - } - }); - this.addRectangleButton.setDisabled(true); - this.addPolygonButton = drawToolbar.toolbarButton({ - id: 'addPolygon', - title: this.ctx.translate.instant('widgets.maps.data-layer.polygon.draw-polygon'), - iconClass: 'tb-draw-polygon', - click: (e, button) => { - this.drawPolygon(e, button); - } - }); - this.addPolygonButton.setDisabled(true); - } - this.addCircleDataLayers = addSupportedDataLayers.filter(dl => dl.dataLayerType() === 'circles'); - if (this.addCircleDataLayers.length) { - this.addCircleButton = drawToolbar.toolbarButton({ - id: 'addCircle', - title: this.ctx.translate.instant('widgets.maps.data-layer.circle.draw-circle'), - iconClass: 'tb-draw-circle', - click: (e, button) => { - this.drawCircle(e, button); - } - }); - this.addCircleButton.setDisabled(true); - } - } + this.editToolbar = L.TB.bottomToolbar({ + mapElement: $(this.mapElement), + closeTitle: this.ctx.translate.instant('action.cancel'), + onClose: () => { + return this.deselectItem(true); + } + }); + + this.map.on('click', () => { + this.deselectItem(); + }); + + if (this.latestDataLayers.some(dl => dl.isEditable())) { + this.map.pm.setGlobalOptions({snappable: false}); + this.map.pm.applyGlobalOptions(); + } + + const dragSupportedDataLayers = this.latestDataLayers.filter(dl => dl.isDragEnabled()); + const showDragModeButton = this.settings.dragModeButton && dragSupportedDataLayers.length; + const addSupportedDataLayers = this.latestDataLayers.filter(dl => dl.isAddEnabled()); + + if (showDragModeButton || addSupportedDataLayers.length) { + const drawToolbar = L.TB.toolbar({ + position: this.settings.controlsPosition + }).addTo(this.map); + if (showDragModeButton) { + this.dragModeButton = drawToolbar.toolbarButton({ + id: 'dragMode', + title: this.ctx.translate.instant('widgets.maps.data-layer.drag-drop-mode'), + iconClass: 'tb-drag-mode', + click: (e, button) => { + this.toggleDragMode(e, button); + } + }); + } + this.addMarkerDataLayers = addSupportedDataLayers.filter(dl => dl.dataLayerType() === 'markers'); + if (this.addMarkerDataLayers.length) { + this.addMarkerButton = drawToolbar.toolbarButton({ + id: 'addMarker', + title: this.ctx.translate.instant('widgets.maps.data-layer.marker.place-marker'), + iconClass: 'tb-place-marker', + click: (e, button) => { + this.placeMarker(e, button); + } + }); + this.addMarkerButton.setDisabled(true); + this.setPlaceMarkerStyle(); + } + this.addPolygonDataLayers = addSupportedDataLayers.filter(dl => dl.dataLayerType() === 'polygons'); + if (this.addPolygonDataLayers.length) { + this.addRectangleButton = drawToolbar.toolbarButton({ + id: 'addRectangle', + title: this.ctx.translate.instant('widgets.maps.data-layer.polygon.draw-rectangle'), + iconClass: 'tb-draw-rectangle', + click: (e, button) => { + this.drawRectangle(e, button); + } + }); + this.addRectangleButton.setDisabled(true); + this.addPolygonButton = drawToolbar.toolbarButton({ + id: 'addPolygon', + title: this.ctx.translate.instant('widgets.maps.data-layer.polygon.draw-polygon'), + iconClass: 'tb-draw-polygon', + click: (e, button) => { + this.drawPolygon(e, button); + } + }); + this.addPolygonButton.setDisabled(true); + } + this.addCircleDataLayers = addSupportedDataLayers.filter(dl => dl.dataLayerType() === 'circles'); + if (this.addCircleDataLayers.length) { + this.addCircleButton = drawToolbar.toolbarButton({ + id: 'addCircle', + title: this.ctx.translate.instant('widgets.maps.data-layer.circle.draw-circle'), + iconClass: 'tb-draw-circle', + click: (e, button) => { + this.drawCircle(e, button); + } + }); + this.addCircleButton.setDisabled(true); + } + this.addPolylineDataLayers = addSupportedDataLayers.filter(dl => dl.dataLayerType() === 'polylines'); + if (this.addPolylineDataLayers.length) { + this.addPolylineButton = drawToolbar.toolbarButton({ + id: 'addPolyline', + title: this.ctx.translate.instant('widgets.maps.data-layer.polyline.draw-polyline'), + iconClass: 'tb-draw-polyline', + click: (e, button) => { + this.drawPolyline(e, button); + } + }); + this.addPolylineButton.setDisabled(true); + } + } } private toggleDragMode(_e: MouseEvent, button: L.TB.ToolbarButton): void { @@ -557,6 +579,13 @@ export abstract class TbMap { })); } + private drawPolyline(e: MouseEvent, button: L.TB.ToolbarButton): void { + this.placeItem(e, button, this.addPolylineDataLayers, (entity) => this.prepareDrawMode('Line', { + startPolyline: this.ctx.translate.instant('widgets.maps.data-layer.polyline.polyline-place-first-point-hint-with-entity', {entityName: entity.entity.entityDisplayName}), + finishPolyline: this.ctx.translate.instant('widgets.maps.data-layer.polyline.finish-polyline-hint-with-entity', {entityName: entity.entity.entityDisplayName}), + })); + } + private placeItem(e: MouseEvent, button: L.TB.ToolbarButton, dataLayers: TbLatestMapDataLayer[], prepareDrawMode: (entity: UnplacedMapDataItem) => void): void { if (this.isPlacingItem) { @@ -678,6 +707,9 @@ export abstract class TbMap { case MapItemType.circle: this.createCircle(actionData); break; + case MapItemType.polyline: + this.createPolyline(actionData); + break; } } @@ -725,6 +757,17 @@ export abstract class TbMap { })); } + private createPolyline(actionData: PlaceMapItemActionData): void { + this.createItem(actionData, () => this.prepareDrawMode('Line', { + startPolyline: actionData.action.mapItemTooltips.startPolyline + ? this.ctx.utilsService.customTranslation(actionData.action.mapItemTooltips.startPolyline) + : this.ctx.translate.instant(mapItemTooltipsTranslation.startPolyline), + finishPolyline: actionData.action.mapItemTooltips.finishPolyline + ? this.ctx.utilsService.customTranslation(actionData.action.mapItemTooltips.finishPolyline) + : this.ctx.translate.instant(mapItemTooltipsTranslation.finishPolyline), + })); + } + private createItem(actionData: PlaceMapItemActionData, prepareDrawMode: () => void) { const actionId = 'id' in actionData.action ? actionData.action.id : 'map-button'; if (this.createMapItemActionId === actionId) { @@ -765,7 +808,7 @@ export abstract class TbMap { this.createMapItemActionId = actionId; - const convertLayerToCoordinates = (type: MapItemType, layer: L.Layer): {x: number; y: number} | TbPolygonRawCoordinates | TbCircleData => { + const convertLayerToCoordinates = (type: MapItemType, layer: L.Layer): { x: number; y: number } | TbPolygonRawCoordinates | TbCircleData => { switch (type) { case MapItemType.marker: if (layer instanceof L.Marker) { @@ -792,6 +835,15 @@ export abstract class TbMap { return this.coordinatesToCircleData(layer.getLatLng(), layer.getRadius()); } return null; + case MapItemType.polyline: + if (layer instanceof L.Polyline) { + let coordinates: any = layer.getLatLngs(); + if (coordinates.length === 1) { + coordinates = coordinates[0]; + } + return this.coordinatesToPolylineData(coordinates); + } + return null; } } } @@ -813,8 +865,8 @@ export abstract class TbMap { this.editToolbar.close(); } - private prepareDrawMode(shape: 'Marker' | 'Rectangle' | 'Polygon' | 'Circle', tooltipsTranslation: Record) { - this.map.pm.setLang('en', { tooltips: tooltipsTranslation }, 'en'); + private prepareDrawMode(shape: 'Marker' | 'Rectangle' | 'Polygon' | 'Circle' | 'Line', tooltipsTranslation: Record) { + this.map.pm.setLang('en', {tooltips: tooltipsTranslation}, 'en'); this.map.pm.enableDraw(shape); // @ts-ignore L.DomUtil.addClass(this.map.pm.Draw[shape]._hintMarker.getTooltip()._container, 'tb-place-item-label'); @@ -848,46 +900,46 @@ export abstract class TbMap { tooltipData.instances = []; } $(root) - .find('a[role="button"]:not(.leaflet-pm-action)') - .each((_index, element) => { - let title: string; - if (element.title) { - title = element.title; - $(element).removeAttr('title'); - } else if (element.parentElement.title) { - title = element.parentElement.title; - $(element).parent().removeAttr('title'); - } - const tooltip = $(element).tooltipster( - { - content: title, - theme: 'tooltipster-shadow', - delay: 10, - triggerClose: { - click: true, - tap: true, - scroll: true, - mouseleave: true - }, - side, - distance: 2, - trackOrigin: true, - functionBefore: (_instance, helper) => { - if (helper.origin.ariaDisabled === 'true' || helper.origin.parentElement.classList.contains('active')) { - return false; - } - }, - } - ); - const instance = tooltip.tooltipster('instance'); - tooltipData.instances.push(instance); - instance.on('destroyed', () => { - const index = tooltipData.instances.indexOf(instance); - if (index > -1) { - tooltipData.instances.splice(index, 1); + .find('a[role="button"]:not(.leaflet-pm-action)') + .each((_index, element) => { + let title: string; + if (element.title) { + title = element.title; + $(element).removeAttr('title'); + } else if (element.parentElement.title) { + title = element.parentElement.title; + $(element).parent().removeAttr('title'); } + const tooltip = $(element).tooltipster( + { + content: title, + theme: 'tooltipster-shadow', + delay: 10, + triggerClose: { + click: true, + tap: true, + scroll: true, + mouseleave: true + }, + side, + distance: 2, + trackOrigin: true, + functionBefore: (_instance, helper) => { + if (helper.origin.ariaDisabled === 'true' || helper.origin.parentElement.classList.contains('active')) { + return false; + } + }, + } + ); + const instance = tooltip.tooltipster('instance'); + tooltipData.instances.push(instance); + instance.on('destroyed', () => { + const index = tooltipData.instances.indexOf(instance); + if (index > -1) { + tooltipData.instances.splice(index, 1); + } + }); }); - }); }); } @@ -899,6 +951,7 @@ export abstract class TbMap { this.updateTripsAnchors(); this.updateBounds(); this.updateEditButtonsStates(); + } private updateTrips(subscription: IWidgetSubscription) { @@ -943,6 +996,7 @@ export abstract class TbMap { private updateTripsAppearance() { this.tripDataLayers.forEach(dl => dl.updateAppearance()); } + private updateTripsTime() { this.tripDataLayers.forEach(dl => dl.updateCurrentTime()); } @@ -995,8 +1049,7 @@ export abstract class TbMap { (!this.bounds || !this.bounds.isValid() || (!this.bounds.equals(bounds) || force) && this.settings.fitMapBounds) && !mapBounds.contains(bounds) ) - ) - { + ) { this.bounds = bounds; if (!this.ignoreUpdateBounds && !this.isPlacingItem) { this.fitBounds(bounds); @@ -1023,6 +1076,9 @@ export abstract class TbMap { if (this.addCircleButton && this.addCircleButton !== this.currentEditButton) { this.addCircleButton.setDisabled(true); } + if (this.addPolylineButton && this.addPolylineButton !== this.currentEditButton) { + this.addPolylineButton.setDisabled(true); + } this.customActionsToolbar?.setDisabled(true); } else { if (this.dragModeButton) { @@ -1040,6 +1096,9 @@ export abstract class TbMap { if (this.addCircleButton) { this.addCircleButton.setDisabled(!this.addCircleDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); } + if (this.addPolylineButton) { + this.addPolylineButton.setDisabled(!this.addPolylineDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); + } this.customActionsToolbar?.setDisabled(false); } } @@ -1150,7 +1209,7 @@ export abstract class TbMap { $event.preventDefault(); $event.stopPropagation(); } - const { entityId, entityName, entityLabel, entityType } = data.$datasource; + const {entityId, entityName, entityLabel, entityType} = data.$datasource; this.ctx.actionsApi.handleWidgetAction($event, action, { entityType, id: entityId @@ -1219,7 +1278,7 @@ export abstract class TbMap { } } - public saveLocation(data: FormattedData, values: {[key: string]: any}): Observable { + public saveLocation(data: FormattedData, values: { [key: string]: any }): Observable { const datasource = data.$datasource; let dataKeys = datasource.dataKeys; if (datasource.latestDataKeys) { @@ -1329,18 +1388,21 @@ export abstract class TbMap { } } - public abstract locationDataToLatLng(position: {x: number; y: number}): L.LatLng; + public abstract locationDataToLatLng(position: { x: number; y: number }): L.LatLng; - public abstract latLngToLocationData(position: L.LatLng): {x: number; y: number}; + public abstract latLngToLocationData(position: L.LatLng): { x: number; y: number }; public abstract polygonDataToCoordinates(coordinates: TbPolygonRawCoordinates): TbPolygonRawCoordinates; public abstract coordinatesToPolygonData(coordinates: TbPolygonCoordinates): TbPolygonRawCoordinates; + public abstract polylineDataToCoordinates(coordinates: TbPolylineRawCoordinates): TbPolylineRawCoordinates; + + public abstract coordinatesToPolylineData(coordinates: TbPolylineCoordinates): TbPolylineRawCoordinates; + public abstract circleDataToCoordinates(circle: TbCircleData): TbCircleData; public abstract coordinatesToCircleData(center: L.LatLng, radius: number): TbCircleData; - } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.html index bc3ad72e58..fe53ef9f66 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.html @@ -20,7 +20,7 @@
    widgets.input-widgets.no-image - last photo + last photo
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.ts index fc5940406c..ff7641bf1a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.ts @@ -135,6 +135,7 @@ export abstract class LatestChartWidgetSettingsComponent
    +
    + + {{ 'tooltip.show-stack-total' | translate }} + +
    {{ 'tooltip.background-color' | translate }}
    } + @case (MapItemType.polyline) { +
    +
    widget-action.map-item-tooltip.start-draw-polyline
    + + + +
    +
    +
    widget-action.map-item-tooltip.finish-draw-polyline
    + + + +
    + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/place-map-item-sample-js.raw b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/place-map-item-sample-js.raw index cb8c23faee..05e06542a5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/place-map-item-sample-js.raw +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/place-map-item-sample-js.raw @@ -79,7 +79,7 @@ function AddEntityDialogController(instance) { const mapType = widgetContext.mapInstance.type(); attributes.push({key: mapType === 'image' ? 'xPos' : 'latitude', value: additionalParams.coordinates.x}); attributes.push({key: mapType === 'image' ? 'yPos' : 'longitude', value: additionalParams.coordinates.y}); - } else if (mapItemType === 'Rectangle' || mapItemType === 'Polygon') { + } else if (mapItemType === 'Rectangle' || mapItemType === 'Polygon' || mapItemType === 'Line' ) { attributes.push({key: 'perimeter', value: additionalParams.coordinates}); } else if (mapItemType === 'Circle') { attributes.push({key: 'circle', value: additionalParams.coordinates}); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html index 0ca912146c..178c948f43 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -163,6 +163,26 @@ (keyEdit)="editKey('circleKey')" formControlName="circleKey"> + +
    + +
    widgets.maps.data-layer.shape.stroke
    @@ -455,7 +477,9 @@ [dsType]="dataLayerFormGroup.get('dsType').value" [dsEntityAliasId]="dataLayerFormGroup.get('dsEntityAliasId').value" [dsDeviceId]="dataLayerFormGroup.get('dsDeviceId').value" - helpId="{{ dataLayerType === 'polygons' ? 'widget/lib/map/polygon_stroke_color_fn' : 'widget/lib/map/circle_stroke_color_fn' }}" formControlName="strokeColor"> + helpId="{{ dataLayerType === 'polygons' ? 'widget/lib/map/polygon_stroke_color_fn' : + (dataLayerType === 'circles' ? 'widget/lib/map/polygon_stroke_color_fn' : 'widget/lib/map/polyline_stroke_color_fn') }}" + formControlName="strokeColor">
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts index 48854adc44..af653308da 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts @@ -29,7 +29,7 @@ import { MarkerType, pathDecoratorSymbols, pathDecoratorSymbolTranslationMap, - PolygonsDataLayerSettings, + PolygonsDataLayerSettings, PolylinesDataLayerSettings, ShapeDataLayerSettings, ShapeFillType, TripsDataLayerSettings, updateDataKeyToNewDsType @@ -113,6 +113,8 @@ export class MapDataLayerDialogComponent extends DialogComponent this.updateValidators() @@ -291,6 +298,15 @@ export class MapDataLayerDialogComponent extends DialogComponent + + +
    -
    -
    +
    +
    +
    + +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts index 747ef21681..06e34e4eea 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts @@ -38,6 +38,7 @@ import { } from '@shared/models/widget/maps/map.models'; import { MapSettingsComponent } from '@home/components/widget/lib/settings/common/map/map-settings.component'; import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; @Component({ selector: 'tb-map-data-layers', @@ -79,6 +80,10 @@ export class MapDataLayersComponent implements ControlValueAccessor, OnInit, Val noDataLayersText: string; + get dragEnabled(): boolean { + return this.dataLayersFormArray().controls.length > 1; + } + private propagateChange = (_val: any) => {}; constructor(private mapSettingsComponent: MapSettingsComponent, @@ -104,6 +109,10 @@ export class MapDataLayersComponent implements ControlValueAccessor, OnInit, Val this.addDataLayerText = 'widgets.maps.data-layer.circle.add-circle'; this.noDataLayersText = 'widgets.maps.data-layer.circle.no-circles'; break; + case 'polylines': + this.addDataLayerText = 'widgets.maps.data-layer.polyline.add-polylines'; + this.noDataLayersText = 'widgets.maps.data-layer.polyline.no-polylines'; + break; } this.dataLayersFormGroup = this.fb.group({ dataLayers: [this.fb.array([]), []] @@ -162,6 +171,13 @@ export class MapDataLayersComponent implements ControlValueAccessor, OnInit, Val removeDataLayer(index: number) { (this.dataLayersFormGroup.get('dataLayers') as UntypedFormArray).removeAt(index); } + + layerDrop(event: CdkDragDrop) { + const layersArray = this.dataLayersFormArray(); + const layer = layersArray.at(event.previousIndex); + layersArray.removeAt(event.previousIndex); + layersArray.insert(event.currentIndex, layer); + } addDataLayer() { const dataLayer = mergeDeep({} as MapDataLayerSettings, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html index fe2afa3b19..c32242cc32 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html @@ -45,6 +45,8 @@ {{ 'widgets.maps.overlays.markers' | translate }} {{ 'widgets.maps.overlays.polygons' | translate }} {{ 'widgets.maps.overlays.circles' | translate }} + //todo translation + Polylines
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts index 5b949bae75..ab32155420 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts @@ -145,6 +145,7 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid markers: [null, []], polygons: [null, []], circles: [null, []], + polylines: [null, []], additionalDataSources: [null, []], controlsPosition: [null, []], zoomActions: [null, []], @@ -180,7 +181,8 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid }); merge(this.mapSettingsFormGroup.get('markers').valueChanges, this.mapSettingsFormGroup.get('polygons').valueChanges, - this.mapSettingsFormGroup.get('circles').valueChanges + this.mapSettingsFormGroup.get('circles').valueChanges, + this.mapSettingsFormGroup.get('polylines').valueChanges ).pipe( takeUntilDestroyed(this.destroyRef) ).subscribe(() => { @@ -281,6 +283,10 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid const polygons: MapDataLayerSettings[] = this.mapSettingsFormGroup.get('polygons').value; dragModeButtonSettingsEnabled = polygons.some(d => d.edit && d.edit.enabledActions && d.edit.enabledActions.includes(DataLayerEditAction.move)); } + if (!dragModeButtonSettingsEnabled) { + const polylines: MapDataLayerSettings[] = this.mapSettingsFormGroup.get('polylines').value; + dragModeButtonSettingsEnabled = polylines.some(d => d.edit && d.edit.enabledActions && d.edit.enabledActions.includes(DataLayerEditAction.move)); + } if (!dragModeButtonSettingsEnabled) { const circles: MapDataLayerSettings[] = this.mapSettingsFormGroup.get('circles').value; dragModeButtonSettingsEnabled = circles.some(d => d.edit && d.edit.enabledActions && d.edit.enabledActions.includes(DataLayerEditAction.move)); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.html index 2225e22a44..cabd91d4e7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.html @@ -15,19 +15,32 @@ limitations under the License. --> -
    -
    - widgets.input-widgets.general-settings - - widgets.input-widgets.widget-title - - -
    -
    - widgets.input-widgets.image-settings -
    - - widgets.input-widgets.image-format +
    +
    +
    widgets.input-widgets.general-settings
    +
    +
    widgets.input-widgets.widget-title
    + + + +
    +
    +
    +
    widgets.input-widgets.save-image
    + + {{ 'widgets.input-widgets.save-to-gallery' | translate }} + + @if (photoCameraInputWidgetSettingsForm.get('saveToGallery').value) { + + {{ 'widgets.input-widgets.public-image' | translate }} + + } +
    +
    +
    widgets.input-widgets.image-settings
    +
    +
    widgets.input-widgets.image-format
    + {{ 'widgets.input-widgets.image-format-jpeg' | translate }} @@ -40,20 +53,33 @@ - - widgets.input-widgets.image-quality - - -
    -
    - - widgets.input-widgets.max-image-width - - - - widgets.input-widgets.max-image-height - - -
    -
    +
    + + @if (photoCameraInputWidgetSettingsForm.get('imageFormat').value !== 'image/png') { +
    +
    widgets.input-widgets.image-quality
    + + + % + +
    + } + +
    +
    Size
    +
    +
    widgets.input-widgets.max-image-width
    + + + px + + +
    widgets.input-widgets.max-image-height
    + + + px + +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.ts index 30c3e39833..4e7d552f44 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.ts @@ -15,10 +15,10 @@ /// import { Component } from '@angular/core'; -import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; -import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; +import { Store } from '@ngrx/store'; +import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; @Component({ selector: 'tb-photo-camera-input-widget-settings', @@ -42,6 +42,8 @@ export class PhotoCameraInputWidgetSettingsComponent extends WidgetSettingsCompo return { widgetTitle: '', + saveToGallery: false, + usePublicGalleryLink: true, imageFormat: 'image/png', imageQuality: 0.92, maxWidth: 640, @@ -57,11 +59,28 @@ export class PhotoCameraInputWidgetSettingsComponent extends WidgetSettingsCompo widgetTitle: [settings.widgetTitle, []], // Image settings - + saveToGallery: [settings.saveToGallery], + usePublicGalleryLink: [settings.usePublicGalleryLink], imageFormat: [settings.imageFormat, []], - imageQuality: [settings.imageQuality, [Validators.min(0), Validators.max(1)]], + imageQuality: [settings.imageQuality, [Validators.min(0), Validators.max(100)]], maxWidth: [settings.maxWidth, [Validators.min(1)]], maxHeight: [settings.maxHeight, [Validators.min(1)]] }); } + + protected prepareInputSettings(settings: WidgetSettings): WidgetSettings { + return { + ...settings, + saveToGallery: settings.saveToGallery ?? false, + usePublicGalleryLink: settings.usePublicGalleryLink ?? false, + imageQuality: settings.imageQuality * 100 + } + } + + protected prepareOutputSettings(settings: WidgetSettings): WidgetSettings { + return { + ...settings, + imageQuality: settings.imageQuality / 100 + } + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts index 7e91a2f043..8f36242d95 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts @@ -451,107 +451,36 @@ export function constructTableCssString(widgetConfig: WidgetConfig): string { const mdDarkDisabled = defaultColor.setAlpha(0.26).toRgbString(); const mdDarkDisabled2 = defaultColor.setAlpha(0.38).toRgbString(); const mdDarkDivider = defaultColor.setAlpha(0.12).toRgbString(); - - const cssString = - '.mat-mdc-input-element::placeholder {\n' + - ' color: ' + mdDarkSecondary + ';\n' + - '}\n' + - '.mat-mdc-input-element::-moz-placeholder {\n' + - ' color: ' + mdDarkSecondary + ';\n' + - '}\n' + - '.mat-mdc-input-element::-webkit-input-placeholder {\n' + - ' color: ' + mdDarkSecondary + ';\n' + - '}\n' + - '.mat-mdc-input-element:-ms-input-placeholder {\n' + - ' color: ' + mdDarkSecondary + ';\n' + - '}\n' + - 'mat-toolbar.mat-mdc-table-toolbar {\n' + - 'color: ' + mdDark + ';\n' + - '}\n' + - 'mat-toolbar.mat-mdc-table-toolbar:not([color="primary"]) button.mat-mdc-icon-button mat-icon {\n' + - 'color: ' + mdDarkSecondary + ';\n' + - '}\n' + - '.mat-mdc-tab .mdc-tab__text-label {\n' + - 'color: ' + mdDark + ';\n' + - '}\n' + - '.mat-mdc-tab-header-pagination-chevron {\n' + - 'border-color: ' + mdDark + ';\n' + - '}\n' + - '.mat-mdc-tab-header-pagination-disabled .mat-mdc-tab-header-pagination-chevron {\n' + - 'border-color: ' + mdDarkDisabled2 + ';\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-header-row {\n' + - 'background-color: ' + origBackgroundColor + ';\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-header-cell {\n' + - 'color: ' + mdDarkSecondary + ';\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-cell, .mat-mdc-table .mat-mdc-header-cell {\n' + - 'border-bottom-color: ' + mdDarkDivider + ';\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-cell .mat-mdc-checkbox ' + - '.mdc-checkbox__native-control:focus:enabled:not(:checked):not(:indeterminate):not([data-indeterminate=true])'+ - '~.mdc-checkbox__background, ' + - '.mat-table .mat-header-cell .mat-mdc-checkbox ' + - '.mdc-checkbox__native-control:focus:enabled:not(:checked):not(:indeterminate):not([data-indeterminate=true])'+ - '~.mdc-checkbox__background {\n' + - 'border-color: ' + mdDarkSecondary + ';\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-row .mat-mdc-cell.mat-mdc-table-sticky {\n' + - 'transition: background-color .2s;\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-row.tb-current-entity {\n' + - 'background-color: ' + currentEntityColor + ';\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-row.tb-current-entity .mat-mdc-cell.mat-mdc-table-sticky {\n' + - 'background-color: ' + currentEntityStickyColor + ';\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-row:hover:not(.tb-current-entity) {\n' + - 'background-color: ' + hoverColor + ';\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-row:hover:not(.tb-current-entity) .mat-mdc-cell.mat-mdc-table-sticky {\n' + - 'background-color: ' + hoverStickyColor + ';\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-row.mat-row-select.mat-selected:not(.tb-current-entity) {\n' + - 'background-color: ' + selectedColor + ';\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-row.mat-row-select.mat-selected:not(.tb-current-entity) .mat-mdc-cell.mat-mdc-table-sticky {\n' + - 'background-color: ' + selectedStickyColor + ';\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-row .mat-mdc-cell.mat-mdc-table-sticky, .mat-mdc-table .mat-mdc-header-cell.mat-mdc-table-sticky {\n' + - 'background-color: ' + origBackgroundColor + ';\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-row {\n' + - 'color: ' + mdDark + ';\n' + - 'background-color: rgba(0, 0, 0, 0);\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-cell button.mat-mdc-icon-button mat-icon {\n' + - 'color: ' + mdDarkSecondary + ';\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-cell button.mat-mdc-icon-button[disabled][disabled] mat-icon {\n' + - 'color: ' + mdDarkDisabled + ';\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-cell button.mat-mdc-icon-button tb-icon {\n' + - 'color: ' + mdDarkSecondary + ';\n' + - '}\n' + - '.mat-mdc-table .mat-mdc-cell button.mat-mdc-icon-button[disabled][disabled] tb-icon {\n' + - 'color: ' + mdDarkDisabled + ';\n' + - '}\n' + - '.mat-divider {\n' + - 'border-top-color: ' + mdDarkDivider + ';\n' + - '}\n' + - '.mat-mdc-paginator {\n' + - 'color: ' + mdDarkSecondary + ';\n' + - '}\n' + - '.mat-mdc-paginator button.mat-mdc-icon-button {\n' + - 'color: ' + mdDarkSecondary + ';\n' + - '}\n' + - '.mat-mdc-paginator button.mat-mdc-icon-button[disabled][disabled] {\n' + - 'color: ' + mdDarkDisabled + ';\n' + - '}\n' + - '.mat-mdc-paginator .mat-mdc-select-value {\n' + - 'color: ' + mdDarkSecondary + ';\n' + - '}'; + + const cssString = ` { + --mat-toolbar-container-text-color: ${mdDark}; + --mat-tab-header-active-label-text-color: ${mdDark}; + --mat-tab-header-inactive-label-text-color: ${mdDark}; + --mat-tab-header-pagination-icon-color: ${mdDark}; + --mat-tab-header-pagination-disabled-icon-color: ${mdDarkDisabled2}; + --mat-table-header-headline-color: ${mdDarkSecondary}; + --mat-table-row-item-label-text-color: ${mdDark}; + --mat-icon-color: ${mdDarkSecondary}; + --mdc-icon-button-disabled-icon-color: ${mdDarkDisabled}; + --mat-divider-color: ${mdDarkDivider}; + --mat-paginator-container-text-color: ${mdDarkSecondary}; + --mdc-icon-button-icon-color: ${mdDarkSecondary}; + --mat-paginator-enabled-icon-color: ${mdDarkSecondary}; + --mat-paginator-disabled-icon-color: ${mdDarkDisabled}; + --mat-select-enabled-trigger-text-color: ${mdDarkSecondary}; + --mat-select-disabled-trigger-text-color: ${mdDarkDisabled}; + --mat-table-row-item-outline-color: ${mdDarkDivider}; + --mdc-checkbox-unselected-focus-icon-color: ${mdDarkSecondary}; + + --tb-orig-background-color: ${origBackgroundColor}; + --tb-current-entity-color: ${currentEntityColor}; + --tb-current-entity-sticky-color: ${currentEntityStickyColor}; + --tb-hover-color: ${hoverColor}; + --tb-hover-sticky-color: ${hoverStickyColor}; + --tb-selected-color: ${selectedColor}; + --tb-selected-sticky-color: ${selectedStickyColor}; + } + `; return cssString; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.scss b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.scss index 3df2c7d0a2..dd8a026db4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.scss @@ -44,7 +44,53 @@ padding: 0 5px; } } + + .mat-mdc-tab-header-pagination-disabled .mat-mdc-tab-header-pagination-chevron { + border-color: var(--mat-tab-header-pagination-disabled-icon-color); + } + } + + .mat-mdc-input-element::placeholder { + color: var(--mat-table-header-headline-color); } + + .mat-mdc-table { + .mat-mdc-header-row { + background-color: var(--tb-orig-background-color); + } + + .mat-mdc-cell, .mat-mdc-header-cell { + &.mat-mdc-table-sticky { + background-color:var(--tb-orig-background-color); + } + } + + .mat-mdc-row { + background-color: rgba(0,0,0,0); + &.tb-current-entity { + background-color: var(--tb-current-entity-color); + .mat-mdc-cell.mat-mdc-table-sticky { + background-color: var(--tb-current-entity-sticky-color); + } + } + &:hover:not(.tb-current-entity){ + background-color: var(--tb-hover-color); + .mat-mdc-cell.mat-mdc-table-sticky { + background-color: var(--tb-hover-sticky-color); + } + } + &.mat-row-select.mat-selected:not(.tb-current-entity){ + background-color: var(--tb-selected-color); + .mat-mdc-cell.mat-mdc-table-sticky { + background-color: var(--tb-selected-sticky-color); + } + } + &, .mat-mdc-cell.mat-mdc-table-sticky { + transition: background-color .2s; + background-color:var(--tb-orig-background-color); + } + } + } } :host-context(.tb-has-timewindow) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html index 9f13f20331..73ab2e679c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html @@ -41,13 +41,13 @@ class="flex-1" mat-stretch-tabs="false" [(selectedIndex)]="sourceIndex" (selectedIndexChange)="onSourceIndexChanged()"> - +
    - Timestamp + {{ 'widgets.table.timestamp-column-name' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts index 303593af26..dbd4905299 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts @@ -107,11 +107,18 @@ import { FormBuilder } from '@angular/forms'; import { DEFAULT_OVERLAY_POSITIONS } from '@shared/models/overlay.models'; import { DateFormatSettings, ValueFormatProcessor } from '@shared/models/widget-settings.models'; +export enum TabSortKey { + NAME_ASC = 'NAME_ASC', + NAME_DESC = 'NAME_DESC', + TIMESTAMP = 'timestamp' +} + export interface TimeseriesTableWidgetSettings extends TableWidgetSettings { showTimestamp: boolean; showMilliseconds: boolean; hideEmptyLines: boolean; dateFormat: DateFormatSettings; + tabSortKey: TabSortKey; } interface TimeseriesWidgetLatestDataKeySettings extends TableWidgetDataKeySettings { @@ -152,6 +159,7 @@ interface TimeseriesTableSource { timeseriesDatasource: TimeseriesDatasource; header: TimeseriesHeader[]; rowDataTemplate: {[key: string]: any}; + displayName: string; } @Component({ @@ -392,12 +400,33 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI this.updateDatasources(); } - public getTabLabel(source: TimeseriesTableSource){ - if (this.useEntityLabel) { - return source.datasource.entityLabel || source.datasource.entityName; - } else { - return source.datasource.entityName; + private getTabLabel(source: Datasource, entityLabelCache:Map):string { + if (entityLabelCache.has(source.entityId)) { + return entityLabelCache.get(source.entityId); + } + + const value = this.useEntityLabel + ? (source.entityLabel || source.entityName) + : source.entityName; + + const translated = this.utils.customTranslation(value); + entityLabelCache.set(source.entityId, translated); + return translated; + } + + private sortDatasources(source: Datasource[], entityLabelCache:Map): Datasource[] { + source.forEach(ds => this.getTabLabel(ds, entityLabelCache)); + + if (this.settings.tabSortKey === TabSortKey.TIMESTAMP) { + return source; } + return source.sort((a, b) => { + const valueA = entityLabelCache.get(a.entityId); + const valueB = entityLabelCache.get(b.entityId); + return this.settings.tabSortKey === TabSortKey.NAME_ASC + ? valueA.localeCompare(valueB) + : valueB.localeCompare(valueA); + }); } private updateDatasources() { @@ -405,9 +434,11 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI this.sourceIndex = 0; let keyOffset = 0; let latestKeyOffset = 0; + const entityLabelCache = new Map(); const pageSize = this.displayPagination ? this.defaultPageSize : Number.POSITIVE_INFINITY; if (this.datasources) { - for (const datasource of this.datasources) { + const sortedDatasources = this.sortDatasources(this.datasources, entityLabelCache); + for (const datasource of sortedDatasources) { const sortOrder: SortOrder = sortOrderFromString(this.defaultSortOrder); const source = {} as TimeseriesTableSource; source.header = this.prepareHeader(datasource); @@ -424,6 +455,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI source.pageLink = new PageLink(pageSize, 0, null, sortOrder); source.rowDataTemplate = {}; source.rowDataTemplate.Timestamp = null; + source.displayName = entityLabelCache.get(datasource.entityId); if (this.showTimestamp) { source.displayedColumns.push('0'); } @@ -513,7 +545,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI let title = ''; const header = this.sources[index].header.find(column => column.index.toString() === value); if (value === '0') { - title = 'Timestamp'; + title = this.translate.instant('widgets.table.timestamp-column-name'); } else if (value === 'actions') { title = 'Actions'; } else { diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts index e34783f5b7..c23619f5c0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts @@ -926,7 +926,26 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe if (this.modelValue) { const configuredColumns = new Array(); if (this.modelValue.config?.datasources[0]?.dataKeys?.length) { - configuredColumns.push(...this.keysToCellClickColumns(this.modelValue.config.datasources[0].dataKeys)); + const { + displayEntityLabel, + displayEntityName, + displayEntityType, + entityNameColumnTitle, + entityLabelColumnTitle + } = this.modelValue.config.settings; + const displayEntitiesArray = []; + if (isDefined(displayEntityName)) { + const displayName = entityNameColumnTitle ? entityNameColumnTitle : 'entityName'; + displayEntitiesArray.push({name: displayName, label: displayName}); + } + if (isDefined(displayEntityLabel)) { + const displayLabel = entityLabelColumnTitle ? entityLabelColumnTitle : 'entityLabel'; + displayEntitiesArray.push({name: displayLabel, label: displayLabel}); + } + if (isDefined(displayEntityType)) { + displayEntitiesArray.push({name: 'entityType', label: 'entityType'}); + } + configuredColumns.push(...displayEntitiesArray, ...this.keysToCellClickColumns(this.modelValue.config.datasources[0].dataKeys)); } if (this.modelValue.config?.alarmSource?.dataKeys?.length) { configuredColumns.push(...this.keysToCellClickColumns(this.modelValue.config.alarmSource.dataKeys)); diff --git a/ui-ngx/src/app/modules/home/home.component.html b/ui-ngx/src/app/modules/home/home.component.html index 48b6105bf9..3d4e35f1d0 100644 --- a/ui-ngx/src/app/modules/home/home.component.html +++ b/ui-ngx/src/app/modules/home/home.component.html @@ -62,6 +62,7 @@ placeholder="{{ 'common.enter-search' | translate }}"/> + + + + + +
    +
    + + +
    + +
    + + +
    + diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/import-dashboard-file-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/dashboard/import-dashboard-file-dialog.component.ts new file mode 100644 index 0000000000..01fca78bf9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/dashboard/import-dashboard-file-dialog.component.ts @@ -0,0 +1,89 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, OnInit } 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, FormGroup } from '@angular/forms'; +import { DashboardService } from '@core/http/dashboard.service'; +import { Dashboard } from '@app/shared/models/dashboard.models'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; + +export interface DashboardInfoDialogData { + dashboard: Dashboard; +} + +@Component({ + selector: 'tb-import-dashboard-file-dialog', + templateUrl: './import-dashboard-file-dialog.component.html', + styleUrls: [] +}) +export class ImportDashboardFileDialogComponent extends DialogComponent implements OnInit { + + private dashboard: Dashboard; + currentFileName: string = ''; + uploadFileFormGroup: FormGroup; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: DashboardInfoDialogData, + private dashboardService: DashboardService, + protected dialogRef: MatDialogRef, + private fb: FormBuilder) { + super(store, router, dialogRef); + this.dashboard = data.dashboard; + } + + ngOnInit(): void { + this.uploadFileFormGroup = this.fb.group({ + file: [null] + }); + } + + cancel(): void { + this.dialogRef.close(); + } + + save() { + const fileControl = this.uploadFileFormGroup.get('file'); + if (!fileControl || !fileControl.value) { + return; + } + + const dashboardContent = { + ...fileControl.value, + description: this.dashboard.configuration.description + }; + this.dashboard.configuration = dashboardContent; + + this.dashboardService.saveDashboard(this.dashboard).subscribe(() => { + this.dialogRef.close(true); + }) + } + + loadDataFromJsonContent(content: string): any { + try { + const importData = JSON.parse(content); + return importData ? importData['configuration'] : importData; + } catch (err) { + this.store.dispatch(new ActionNotificationShow({message: err.message, type: 'error'})); + return null; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html index b9003c4c1c..b9b0292043 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html @@ -15,54 +15,92 @@ limitations under the License. --> - -
    - - device-profile.transport-type - - - {{deviceTransportTypeTranslations.get(type) | translate}} - - - - {{deviceTransportTypeHints.get(detailsForm.get('transportType').value) | translate}} - - - {{ 'device-profile.transport-type-required' | translate }} - - -
    - - -
    -
    -
    - - - - -
    -
    - +@if (entity) { + +
    + + device-profile.transport-type + + + {{deviceTransportTypeTranslations.get(type) | translate}} + + + + {{deviceTransportTypeHints.get(detailsForm.get('transportType').value) | translate}} + + + {{ 'device-profile.transport-type-required' | translate }} + + +
    + + +
    -
    - - -
    -
    - - + + @if (authUser.authority === authorities.TENANT_ADMIN && !isEdit) { + + + + } + + @if (hasOldRules || authUser.authority === authorities.TENANT_ADMIN && !isEdit) { + +
    + @if (hasOldRules && !isEdit) { +
    + + {{ 'alarm-rule.alarm-rules-actual' | translate }} + {{ 'alarm-rule.alarm-rules-old' | translate }} + +
    + } + @if (alarmRulesOldVersion || isEdit) { +
    +
    + +
    +
    + } @else { +
    + + +
    + } +
    +
    + } + + +
    +
    + + +
    -
    - + + @if (!isEdit) { + + + + } + @if (authUser.authority === authorities.TENANT_ADMIN && !isEdit) { + + + + + } +}
    @@ -73,13 +111,3 @@
    - - - - - - diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts index 1e4b735ff7..629c9eeeef 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts @@ -41,6 +41,9 @@ export class DeviceProfileTabsComponent extends EntityTabsComponent, private destroyRef: DestroyRef) { super(store); @@ -57,6 +60,8 @@ export class DeviceProfileTabsComponent extends EntityTabsComponent - - - - - - - - - - - - - - - - - - - - - - - - - - +@if (entity) { + + + + + + + + + @if (authUser.authority === authorities.TENANT_ADMIN) { + + + + + + + } + + + + + + + + + + + + + @if (authUser.authority === authorities.TENANT_ADMIN) { + + + + } +} diff --git a/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.html b/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.html index f88244add1..05f872ab25 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.html @@ -39,6 +39,7 @@
    {{ 'mobile.bundle' | translate }}
    - - + + {{ recipientTitle }} - - + +
    @@ -233,7 +233,7 @@
    {{ preview.processedTemplates.MICROSOFT_TEAMS.subject }}
    {{ preview.processedTemplates.MICROSOFT_TEAMS.body }} - +
    diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html index 44eea5865e..c3cf5ea0f0 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html @@ -37,7 +37,7 @@
    user.email - + {{ 'user.email-required' | translate }} @@ -58,12 +58,14 @@ [enableFlagsSelect]="true" formControlName="phone"> - + language.language - - - {{ lang ? ('language.locales.' + lang | translate) : ''}} - + + {{ 'language.auto' | translate }} + @for(lang of languageList; track lang) { + {{ 'language.locales.' + lang | translate }} + } diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts b/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts index 7d1aa2aa4e..32d0c6aba8 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts @@ -25,10 +25,9 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; import { ActionAuthUpdateUserDetails } from '@core/auth/auth.actions'; import { environment as env } from '@env/environment'; -import { TranslateService } from '@ngx-translate/core'; import { ActionSettingsChangeLanguage } from '@core/settings/settings.actions'; import { ActivatedRoute } from '@angular/router'; -import { isDefinedAndNotNull, isNotEmptyStr } from '@core/utils'; +import { isDefinedAndNotNull, isNotEmptyStr, validateEmail } from '@core/utils'; import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { AuthService } from '@core/auth/auth.service'; import { UnitSystem, UnitSystems } from '@shared/models/unit.models'; @@ -52,7 +51,6 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir private route: ActivatedRoute, private userService: UserService, private authService: AuthService, - private translate: TranslateService, private unitService: UnitService, private fb: UntypedFormBuilder) { super(store); @@ -66,7 +64,7 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir private buildProfileForm() { this.profile = this.fb.group({ - email: ['', [Validators.required, Validators.email]], + email: ['', [Validators.required, validateEmail]], firstName: [''], lastName: [''], phone: [''], @@ -82,9 +80,13 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir if (!this.user.additionalInfo) { this.user.additionalInfo = {}; } - this.user.additionalInfo.lang = this.profile.get('language').value; this.user.additionalInfo.homeDashboardId = this.profile.get('homeDashboardId').value; this.user.additionalInfo.homeDashboardHideToolbar = this.profile.get('homeDashboardHideToolbar').value; + if (isNotEmptyStr(this.profile.get('language').value)) { + this.user.additionalInfo.lang = this.profile.get('language').value; + } else { + delete this.user.additionalInfo.lang; + } if (isNotEmptyStr(this.profile.get('unitSystem').value)) { this.user.additionalInfo.unitSystem = this.profile.get('unitSystem').value; } else { @@ -105,7 +107,7 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir id: user.id, lastName: user.lastName, } })); - this.store.dispatch(new ActionSettingsChangeLanguage({ userLang: user.additionalInfo.lang })); + this.store.dispatch(new ActionSettingsChangeLanguage({ userLang: user.additionalInfo.lang || env.defaultLang })); this.unitService.setUnitSystem(this.user.additionalInfo.unitSystem); this.authService.refreshJwtToken(false); } @@ -115,7 +117,7 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir private userLoaded(user: User) { this.user = user; this.profile.reset(user); - let lang; + let lang: string = null; let homeDashboardId; let homeDashboardHideToolbar = true; let unitSystem: UnitSystem = null; @@ -131,9 +133,6 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir unitSystem = user.additionalInfo.unitSystem; } } - if (!lang) { - lang = this.translate.currentLang; - } this.profile.get('language').setValue(lang); this.profile.get('unitSystem').setValue(unitSystem); this.profile.get('homeDashboardId').setValue(homeDashboardId); diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts index 2ac9b806e6..f7ead66646 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts @@ -26,6 +26,7 @@ import { OnInit, QueryList, Renderer2, + SecurityContext, SkipSelf, ViewChild, ViewChildren, @@ -97,6 +98,7 @@ import { HttpStatusCode } from '@angular/common/http'; import { TbContextMenuEvent } from '@shared/models/jquery-event.models'; import { EntityDebugSettings } from '@shared/models/entity.models'; import Timeout = NodeJS.Timeout; +import { DomSanitizer } from '@angular/platform-browser'; @Component({ selector: 'tb-rulechain-page', @@ -273,6 +275,7 @@ export class RuleChainPageComponent extends PageComponent private renderer: Renderer2, private viewContainerRef: ViewContainerRef, private changeDetector: ChangeDetectorRef, + private sanitizer:DomSanitizer, public dialog: MatDialog, public dialogService: DialogService, public fb: FormBuilder) { @@ -1360,9 +1363,13 @@ export class RuleChainPageComponent extends PageComponent name = node.name; desc = this.translate.instant(ruleNodeTypeDescriptors.get(node.component.type).name) + ' - ' + node.component.name; if (node.additionalInfo) { - details = node.additionalInfo.description; + details = this.sanitizer.sanitize(SecurityContext.HTML, node.additionalInfo.description); } } + + name = this.sanitizer.sanitize(SecurityContext.HTML, name); + desc = this.sanitizer.sanitize(SecurityContext.HTML, desc); + let tooltipContent = '
    ' + '
    ' + '
    ' + name + '
    ' + diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html index 0175c85f56..6eba3f8cf7 100644 --- a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html @@ -39,7 +39,7 @@
    {{ 'user.email-required' | translate }} diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts index 601c2e9277..6ed9099522 100644 --- a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts @@ -28,6 +28,7 @@ import { TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models'; import { MatStepper } from '@angular/material/stepper'; +import { validateEmail } from '@app/core/utils'; export interface EmailAuthDialogData { email: string; @@ -57,7 +58,7 @@ export class EmailAuthDialogComponent extends DialogComponent

    security.2fa.dialog.scan-qr-code

    +

    login.enter-key-manually

    +
    + {{ totpAuthURLSecret }} + + +

    security.2fa.dialog.enter-verification-code

    ; @@ -55,6 +56,7 @@ export class TotpAuthDialogComponent extends DialogComponent { this.authAccountConfig = accountConfig as TotpTwoFactorAuthAccountConfig; this.totpAuthURL = this.authAccountConfig.authUrl; + this.totpAuthURLSecret = new URL(this.totpAuthURL).searchParams.get('secret'); this.authAccountConfig.useByDefault = true; import('qrcode').then((QRCode) => { unwrapModule(QRCode).toCanvas(this.canvasRef.nativeElement, this.totpAuthURL); diff --git a/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.html b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.html index 8f4a8a4a6a..c9a83c1531 100644 --- a/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.html @@ -31,7 +31,7 @@
    - + user.activation-method diff --git a/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts index 7470deb3d6..11468f64fb 100644 --- a/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts @@ -84,6 +84,12 @@ export class AddUserDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/pages/user/user.component.html b/ui-ngx/src/app/modules/home/pages/user/user.component.html index 658b361255..6e654cf845 100644 --- a/ui-ngx/src/app/modules/home/pages/user/user.component.html +++ b/ui-ngx/src/app/modules/home/pages/user/user.component.html @@ -74,9 +74,9 @@
    - + user.email - + {{ 'user.invalid-email-format' | translate }} @@ -84,30 +84,52 @@ {{ 'user.email-required' | translate }} - + user.first-name - + user.last-name
    - + + language.language + + {{ 'language.auto' | translate }} + @for(lang of languageList; track lang) { + {{ 'language.locales.' + lang | translate }} + } + + + + unit.unit-system + + {{ 'unit.unit-system-type.AUTO' | translate }} + @for(unit of UnitSystems; track unit) { + {{ 'unit.unit-system-type.' + unit | translate }} + } + + + user.description
    -
    +
    -
    +
    { +export class UserComponent extends EntityComponent{ authority = Authority; + languageList = env.supportedLangs; + UnitSystems = UnitSystems; loginAsUserEnabled$ = this.store.pipe( select(selectAuth), @@ -70,13 +74,15 @@ export class UserComponent extends EntityComponent { buildForm(entity: User): UntypedFormGroup { return this.fb.group( { - email: [entity ? entity.email : '', [Validators.required, Validators.email]], + email: [entity ? entity.email : '', [Validators.required, validateEmail]], firstName: [entity ? entity.firstName : ''], lastName: [entity ? entity.lastName : ''], phone: [entity ? entity.phone : ''], additionalInfo: this.fb.group( { description: [entity && entity.additionalInfo ? entity.additionalInfo.description : ''], + lang: [entity && entity.additionalInfo ? entity.additionalInfo.lang : null], + unitSystem: [entity && entity.additionalInfo ? entity.additionalInfo.unitSystem : null], defaultDashboardId: [entity && entity.additionalInfo ? entity.additionalInfo.defaultDashboardId : null], defaultDashboardFullscreen: [entity && entity.additionalInfo ? entity.additionalInfo.defaultDashboardFullscreen : false], homeDashboardId: [entity && entity.additionalInfo ? entity.additionalInfo.homeDashboardId : null], @@ -94,6 +100,10 @@ export class UserComponent extends EntityComponent { this.entityForm.patchValue({lastName: entity.lastName}); this.entityForm.patchValue({phone: entity.phone}); this.entityForm.patchValue({additionalInfo: {description: entity.additionalInfo ? entity.additionalInfo.description : ''}}); + this.entityForm.patchValue({additionalInfo: + {lang: entity.additionalInfo ? entity.additionalInfo.lang : null}}); + this.entityForm.patchValue({additionalInfo: + {unitSystem: entity.additionalInfo ? entity.additionalInfo.unitSystem : null}}); this.entityForm.patchValue({additionalInfo: {defaultDashboardId: entity.additionalInfo ? entity.additionalInfo.defaultDashboardId : null}}); this.entityForm.patchValue({additionalInfo: diff --git a/ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts index 2e056119d7..651da8d99f 100644 --- a/ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts @@ -158,6 +158,12 @@ export class UsersTableConfigResolver { user.tenantId = new TenantId(this.tenantId); user.customerId = new CustomerId(this.customerId); user.authority = this.authority; + if (!user.additionalInfo.lang) { + delete user.additionalInfo.lang; + } + if (!user.additionalInfo.unitSystem) { + delete user.additionalInfo.unitSystem; + } return this.userService.saveUser(user); } diff --git a/ui-ngx/src/app/modules/login/login-routing.module.ts b/ui-ngx/src/app/modules/login/login-routing.module.ts index 3000d81fb8..a6e852958c 100644 --- a/ui-ngx/src/app/modules/login/login-routing.module.ts +++ b/ui-ngx/src/app/modules/login/login-routing.module.ts @@ -25,6 +25,7 @@ import { CreatePasswordComponent } from '@modules/login/pages/login/create-passw import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component'; import { Authority } from '@shared/models/authority.enum'; import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.component'; +import { ForceTwoFactorAuthLoginComponent } from '@modules/login/pages/login/force-two-factor-auth-login.component'; const routes: Routes = [ { @@ -83,6 +84,16 @@ const routes: Routes = [ }, canActivate: [AuthGuard] }, + { + path: 'login/force-mfa', + component: ForceTwoFactorAuthLoginComponent, + data: { + title: 'login.two-factor-authentication', + auth: [Authority.MFA_CONFIGURATION_TOKEN], + module: 'public' + }, + canActivate: [AuthGuard] + }, { path: 'activationLinkExpired', component: LinkExpiredComponent, diff --git a/ui-ngx/src/app/modules/login/login.module.ts b/ui-ngx/src/app/modules/login/login.module.ts index 35dbfad7e2..9b5d1d7711 100644 --- a/ui-ngx/src/app/modules/login/login.module.ts +++ b/ui-ngx/src/app/modules/login/login.module.ts @@ -25,6 +25,7 @@ import { ResetPasswordComponent } from '@modules/login/pages/login/reset-passwor import { CreatePasswordComponent } from '@modules/login/pages/login/create-password.component'; import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component'; import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.component'; +import { ForceTwoFactorAuthLoginComponent } from '@modules/login/pages/login/force-two-factor-auth-login.component'; @NgModule({ declarations: [ @@ -33,7 +34,8 @@ import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.co ResetPasswordComponent, CreatePasswordComponent, TwoFactorAuthLoginComponent, - LinkExpiredComponent + LinkExpiredComponent, + ForceTwoFactorAuthLoginComponent, ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html new file mode 100644 index 0000000000..39236ef8e2 --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html @@ -0,0 +1,304 @@ + + + + + + + + + diff --git a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss new file mode 100644 index 0000000000..d62d7f1628 --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss @@ -0,0 +1,110 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + display: flex; + flex: 1 1 0; + width: 100%; + height: 100%; + + .tb-two-factor-auth-login-content { + background-color: #eee; + + .tb-two-factor-auth-login-card { + max-height: 100vh; + overflow: auto; + padding: 48px 48px 48px 16px; + + @media #{$mat-xs} { + height: 100%; + } + + @media #{$mat-gt-xs} { + width: 450px !important; + } + + .mat-mdc-card-title { + font: 400 28px / 36px Roboto, "Helvetica Neue", sans-serif; + } + + .mat-mdc-card-header { + padding: 0; + } + + .mat-mdc-card-content { + margin-top: 34px; + margin-left: 40px; + padding: 0; + } + + .mat-body { + letter-spacing: 0.25px; + line-height: 16px; + } + + .backup-code { + p { + text-align: justify; + } + + .container { + border: 1px solid; + border-radius: 4px; + gap: 16px; + display: grid; + grid-template-columns: 1fr 1fr; + justify-items: center; + padding: 16px 0; + margin-bottom: 16px; + + .code { + letter-spacing: 0.25px; + font-family: Roboto Mono, "Helvetica Neue", monospace; + } + } + + .action-buttons { + margin-bottom: 40px; + } + } + } + } + + ::ng-deep { + .tb-two-factor-auth-login-content { + .tb-two-factor-auth-login-card { + button.mat-mdc-icon-button { + .mat-icon { + color: rgba(255, 255, 255, 0.8); + } + } + } + .mat-mdc-form-field .mat-mdc-form-field-hint-wrapper { + color: rgba(255, 255, 255, 0.8); + } + } + + button.provider, button.navigation { + text-align: start; + font-weight: 400; + color: rgba(255, 255, 255, 0.8); + &:not([disabled][disabled]) { + border-color: rgba(255, 255, 255, .8); + } + } + } +} diff --git a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts new file mode 100644 index 0000000000..ef1917ba24 --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts @@ -0,0 +1,300 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, ElementRef, OnDestroy, OnInit, signal, ViewChild } from '@angular/core'; +import { AuthService } from '@core/auth/auth.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; +import { + AccountTwoFaSettings, + BackupCodeTwoFactorAuthAccountConfig, + TotpTwoFactorAuthAccountConfig, + TwoFactorAuthAccountConfig, + twoFactorAuthProvidersEnterCodeCardTranslate, + twoFactorAuthProvidersLoginData, + twoFactorAuthProvidersSuccessCardTranslate, + TwoFactorAuthProviderType +} from '@shared/models/two-factor-auth.models'; +import { phoneNumberPattern } from '@shared/models/settings.models'; +import { deepClone, isDefinedAndNotNull, unwrapModule } from '@core/utils'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import printTemplate from '@home/pages/security/authentication-dialog/backup-code-print-template.raw'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { mergeMap, tap } from 'rxjs/operators'; + +enum ForceTwoFAState { + SETUP = 'setup', + AUTHENTICATOR_APP = 'authenticatorApp', + SMS = 'sms', + EMAIL = 'email', + BACKUP_CODE = 'backupCode', +} + +enum ProvidersState { + INPUT = 'INPUT', + ENTER_CODE = 'ENTER_CODE', + SUCCESS = 'SUCCESS', +} + +enum BackupCodeState { + CODE = 'CODE', + SUCCESS = 'SUCCESS', +} + +@Component({ + selector: 'tb-force-two-factor-auth-login', + templateUrl: './force-two-factor-auth-login.component.html', + styleUrls: ['./force-two-factor-auth-login.component.scss'] +}) +export class ForceTwoFactorAuthLoginComponent extends PageComponent implements OnInit, OnDestroy { + + TwoFactorAuthProviderType = TwoFactorAuthProviderType; + providersData = twoFactorAuthProvidersLoginData; + allowProviders: TwoFactorAuthProviderType[] = []; + config: AccountTwoFaSettings; + + twoFactorAuthProvidersEnterCodeCardTranslate = twoFactorAuthProvidersEnterCodeCardTranslate; + twoFactorAuthProvidersSuccessCardTranslate = twoFactorAuthProvidersSuccessCardTranslate; + + ForceTwoFAState = ForceTwoFAState; + ProvidersState = ProvidersState; + BackupCodeState = BackupCodeState + + state = signal(ForceTwoFAState.SETUP); + appState = signal(ProvidersState.INPUT); + smsState = signal(ProvidersState.INPUT); + emailState = signal(ProvidersState.INPUT); + backupCodeState = signal(BackupCodeState.CODE); + + totpAuthURL: string; + totpAuthURLSecret: string; + backupCode: BackupCodeTwoFactorAuthAccountConfig; + + configForm: UntypedFormGroup; + smsConfigForm: UntypedFormGroup; + emailConfigForm: UntypedFormGroup; + + private providersInfo: TwoFactorAuthProviderType[]; + private authAccountConfig: TwoFactorAuthAccountConfig; + private useByDefault: boolean = true; + + @ViewChild('canvas', {static: false}) canvasRef: ElementRef; + + constructor(protected store: Store, + private authService: AuthService, + private twoFaService: TwoFactorAuthenticationService, + private importExportService: ImportExportService, + public dialog: MatDialog, + public dialogService: DialogService, + private fb: UntypedFormBuilder) { + super(store); + } + + ngOnInit() { + this.providersInfo = this.authService.forceTwoFactorAuthProviders; + this.allowedProviders(); + this.configForm = this.fb.group({ + verificationCode: ['', [ + Validators.required, + Validators.minLength(6), + Validators.maxLength(6), + Validators.pattern(/^\d*$/) + ]] + }); + + this.smsConfigForm = this.fb.group({ + phone: ['', [Validators.required, Validators.pattern(phoneNumberPattern)]] + }); + + this.emailConfigForm = this.fb.group({ + email: [getCurrentAuthUser(this.store).sub, [Validators.required, Validators.email]] + }); + + this.twoFaService.getAccountTwoFaSettings().subscribe(accountConfig => { + if (accountConfig) { + this.config = accountConfig; + this.useByDefault = false; + } + }); + } + + goBackByType(type: TwoFactorAuthProviderType) { + switch (type) { + case TwoFactorAuthProviderType.TOTP: + this.appState.set(ProvidersState.INPUT); + this.updateQRCode(); + break; + case TwoFactorAuthProviderType.SMS: + this.smsState.set(ProvidersState.INPUT); + break; + case TwoFactorAuthProviderType.EMAIL: + this.emailState.set(ProvidersState.INPUT); + break; + } + } + + get isAnyProviderAvailable() { + return this.config?.configs ? Object.keys(this.config?.configs)?.length < this.allowProviders?.length : true; + } + + private allowedProviders() { + if (isDefinedAndNotNull(this.config)) { + this.allowProviders = this.providersInfo; + } else { + this.allowProviders = this.providersInfo.filter(provider => provider !== TwoFactorAuthProviderType.BACKUP_CODE); + } + } + + updateState(type: TwoFactorAuthProviderType) { + switch (type) { + case TwoFactorAuthProviderType.TOTP: + this.state.set(ForceTwoFAState.AUTHENTICATOR_APP); + this.twoFaService.generateTwoFaAccountConfig(TwoFactorAuthProviderType.TOTP).subscribe(accountConfig => { + this.authAccountConfig = accountConfig as TotpTwoFactorAuthAccountConfig; + this.totpAuthURL = this.authAccountConfig.authUrl; + this.totpAuthURLSecret = new URL(this.totpAuthURL).searchParams.get('secret'); + this.authAccountConfig.useByDefault = this.useByDefault; + this.useByDefault = false; + this.updateQRCode(); + }); + break; + case TwoFactorAuthProviderType.SMS: + this.state.set(ForceTwoFAState.SMS); + break; + case TwoFactorAuthProviderType.EMAIL: + this.state.set(ForceTwoFAState.EMAIL); + break; + case TwoFactorAuthProviderType.BACKUP_CODE: + this.state.set(ForceTwoFAState.BACKUP_CODE); + this.twoFaService.generateTwoFaAccountConfig(TwoFactorAuthProviderType.BACKUP_CODE).pipe( + tap((data: BackupCodeTwoFactorAuthAccountConfig) => this.backupCode = data), + mergeMap(data => this.twoFaService.verifyAndSaveTwoFaAccountConfig(data, null, {ignoreLoading: true})) + ).subscribe((config) => { + this.config = config; + }); + break; + } + } + + sendSmsCode() { + if (this.smsConfigForm.valid) { + this.authAccountConfig = { + providerType: TwoFactorAuthProviderType.SMS, + useByDefault: this.useByDefault, + phoneNumber: this.smsConfigForm.get('phone').value as string + }; + this.useByDefault = false; + this.twoFaService.submitTwoFaAccountConfig(this.authAccountConfig).subscribe(() => this.smsState.set(ProvidersState.ENTER_CODE)); + } + } + + sendEmailCode() { + if (this.emailConfigForm.valid) { + this.authAccountConfig = { + providerType: TwoFactorAuthProviderType.EMAIL, + useByDefault: this.useByDefault, + email: this.emailConfigForm.get('email').value as string + }; + this.useByDefault = false; + this.twoFaService.submitTwoFaAccountConfig(this.authAccountConfig).subscribe(() => this.emailState.set(ProvidersState.ENTER_CODE)); + } + } + + tryAnotherWay(type: TwoFactorAuthProviderType) { + this.state.set(ForceTwoFAState.SETUP); + this.configForm.reset(); + switch (type) { + case TwoFactorAuthProviderType.TOTP: + this.appState.set(ProvidersState.INPUT); + break; + case TwoFactorAuthProviderType.SMS: + this.smsState.set(ProvidersState.INPUT); + this.smsConfigForm.reset(); + break; + case TwoFactorAuthProviderType.EMAIL: + this.emailState.set(ProvidersState.INPUT) + this.emailConfigForm.get('email').reset(getCurrentAuthUser(this.store).sub); + break; + } + } + + saveConfig(type: TwoFactorAuthProviderType) { + if (this.configForm.valid) { + this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, + this.configForm.get('verificationCode').value).subscribe((config) => { + switch (type) { + case TwoFactorAuthProviderType.TOTP: + this.appState.set(ProvidersState.SUCCESS); + break; + case TwoFactorAuthProviderType.SMS: + this.smsState.set(ProvidersState.SUCCESS); + break; + case TwoFactorAuthProviderType.EMAIL: + this.emailState.set(ProvidersState.SUCCESS); + break; + } + this.config = config; + this.authAccountConfig = null; + this.allowedProviders(); + }); + } + } + + private updateQRCode() { + import('qrcode').then((QRCode) => { + unwrapModule(QRCode).toCanvas(this.canvasRef.nativeElement, this.totpAuthURL); + this.canvasRef.nativeElement.style.width = 'auto'; + this.canvasRef.nativeElement.style.height = 'auto'; + }); + } + + ngOnDestroy() { + super.ngOnDestroy(); + } + + cancelLogin() { + this.authService.logout(); + } + + downloadFile() { + this.importExportService.exportText(this.backupCode.codes, 'backup-codes'); + } + + printCode() { + const codeTemplate = deepClone(this.backupCode.codes) + .map(code => `
    ${code}
    `).join(''); + const printPage = printTemplate.replace('${codesBlock}', codeTemplate); + const newWindow = window.open('', 'Print backup code'); + + newWindow.document.open(); + newWindow.document.write(printPage); + + setTimeout(() => { + newWindow.print(); + + newWindow.document.close(); + + setTimeout(() => { + newWindow.close(); + }, 10); + }, 0); + } +} diff --git a/ui-ngx/src/app/modules/login/pages/login/login.component.html b/ui-ngx/src/app/modules/login/pages/login/login.component.html index 65b82023fe..c1a4b17a3c 100644 --- a/ui-ngx/src/app/modules/login/pages/login/login.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/login.component.html @@ -43,7 +43,7 @@
    login.username - + email {{ 'login.invalid-email-format' | translate }} diff --git a/ui-ngx/src/app/modules/login/pages/login/login.component.ts b/ui-ngx/src/app/modules/login/pages/login/login.component.ts index aa7642b654..a4bd2853b9 100644 --- a/ui-ngx/src/app/modules/login/pages/login/login.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/login.component.ts @@ -19,11 +19,12 @@ import { AuthService } from '@core/auth/auth.service'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { PageComponent } from '@shared/components/page.component'; -import { UntypedFormBuilder } from '@angular/forms'; +import { UntypedFormBuilder, Validators } from '@angular/forms'; import { HttpErrorResponse } from '@angular/common/http'; import { Constants } from '@shared/models/constants'; import { Router } from '@angular/router'; import { OAuth2ClientLoginInfo } from '@shared/models/oauth2.models'; +import { validateEmail } from '@app/core/utils'; @Component({ selector: 'tb-login', @@ -35,8 +36,8 @@ export class LoginComponent extends PageComponent implements OnInit { passwordViolation = false; loginFormGroup = this.fb.group({ - username: '', - password: '' + username: ['', [Validators.required, validateEmail]], + password: [''] }); oauth2Clients: Array = null; diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html index 96b85f2e47..abf2e6c61c 100644 --- a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html @@ -33,7 +33,7 @@ login.email - + email {{ 'user.invalid-email-format' | translate }} diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts index 98b3d97398..3daeaa693d 100644 --- a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts @@ -22,6 +22,7 @@ import { PageComponent } from '@shared/components/page.component'; import { UntypedFormBuilder, Validators } from '@angular/forms'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { TranslateService } from '@ngx-translate/core'; +import { validateEmail } from '@app/core/utils'; @Component({ selector: 'tb-reset-password-request', @@ -33,7 +34,7 @@ export class ResetPasswordRequestComponent extends PageComponent implements OnIn clicked: boolean = false; requestPasswordRequest = this.fb.group({ - email: ['', [Validators.email, Validators.required]] + email: ['', [Validators.required, validateEmail]], }, {updateOn: 'submit'}); constructor(protected store: Store, diff --git a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss index 68a4dcb4a4..09e3c29c43 100644 --- a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss +++ b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss @@ -72,9 +72,19 @@ } ::ng-deep { + .tb-two-factor-auth-login-content { + .tb-two-factor-auth-login-card { + button.mat-mdc-icon-button { + .mat-icon { + color: rgba(255, 255, 255, 0.8); + } + } + } + } button.provider { text-align: start; font-weight: 400; + color: rgba(255, 255, 255, 0.8); &:not([disabled][disabled]) { border-color: rgba(255, 255, 255, .8); } diff --git a/ui-ngx/src/app/shared/components/color-picker/hex-input.component.html b/ui-ngx/src/app/shared/components/color-picker/hex-input.component.html index 5209bc6b24..0d83545c63 100644 --- a/ui-ngx/src/app/shared/components/color-picker/hex-input.component.html +++ b/ui-ngx/src/app/shared/components/color-picker/hex-input.component.html @@ -30,7 +30,7 @@ - + %
    diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html index db7300530e..4e03ca09de 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html @@ -18,7 +18,6 @@ - +
    diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts index 9137a3b2d7..6f8b39c171 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts @@ -22,7 +22,7 @@ import { catchError, debounceTime, map, share, switchMap, tap } from 'rxjs/opera import { Store } from '@ngrx/store'; import { AppState } from '@app/core/core.state'; import { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; -import { BaseData } from '@shared/models/base-data'; +import { BaseData, getEntityDisplayName } from '@shared/models/base-data'; import { EntityId } from '@shared/models/id/entity-id'; import { EntityService } from '@core/http/entity.service'; import { getCurrentAuthUser } from '@core/auth/auth.selectors'; @@ -138,6 +138,10 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit @coerceArray() additionalClasses: Array; + @Input() + @coerceBoolean() + useEntityDisplayName = false; + @Output() entityChanged = new EventEmitter>(); @@ -395,7 +399,7 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit } displayEntityFn(entity?: BaseData): string | undefined { - return entity ? entity.name : undefined; + return entity ? (this.useEntityDisplayName ? getEntityDisplayName(entity) : entity.name) : undefined; } private fetchEntities(searchText?: string): Observable>> { diff --git a/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html index efc90ec048..c08355462f 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html @@ -22,13 +22,13 @@ required (focusin)="keyInputSubject.next()" [matAutocomplete]="keysAutocomplete"> - @if (keyControl.value) { + @if (keyControl.value && keyControl.enabled) { - } @else if (keyControl.hasError('required') && keyControl.touched) { + } @else if (keyControl.hasError('required') && keyControl.touched && keyControl.enabled) { { this.cachedResult = result; @@ -133,6 +133,7 @@ export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Val if (filterChanged || keyScopeChanged || keyTypeChanged) { this.keyControl.setValue('', {emitEvent: false}); + this.cachedResult = null; } } @@ -151,10 +152,18 @@ export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Val registerOnTouched(_): void {} validate(): ValidationErrors | null { - return this.keyControl.valid ? null : { keyControl: false }; + return this.keyControl.valid || this.keyControl.disabled ? null : { keyControl: false }; } writeValue(value: string): void { this.keyControl.patchValue(value, {emitEvent: false}); } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.keyControl.disable({emitEvent: false}); + } else { + this.keyControl.enable({emitEvent: false}); + } + } } diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html index f3983111bf..0ae793a1ac 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html @@ -36,6 +36,7 @@ *ngIf="modelValue.entityType" [required]="required" [entityType]="modelValue.entityType" + [useEntityDisplayName]="useEntityDisplayName" formControlName="entityIds">
    diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts index e4bef51d15..7c2c4e7f2a 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts @@ -68,6 +68,9 @@ export class EntityListSelectComponent implements ControlValueAccessor, OnInit { @Input() additionEntityTypes: {[key in string]: string} = {}; + @Input({transform: booleanAttribute}) + useEntityDisplayName = false; + displayEntityTypeSelect: boolean; private defaultEntityType: EntityType | AliasEntityType = null; diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-list.component.html index 6bf7cdb78d..33c684285e 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.html @@ -28,7 +28,7 @@ class="tb-chip-row-ellipsis" [removable]="!disabled" (removed)="remove(entity)"> - {{entity.name}} + {{ displayEntityFn(entity) }} close + - +
    @@ -56,6 +62,11 @@ {{ 'entity.no-entities-matching' | translate: {entity: searchText} }} + @if (allowCreateNew) { + + entity.create-new-key + + }
    diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts index 552c4f1f71..f93172c9b0 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts @@ -14,7 +14,18 @@ /// limitations under the License. /// -import { Component, ElementRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { + Component, + ElementRef, + EventEmitter, + forwardRef, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, + ViewChild +} from '@angular/core'; import { ControlValueAccessor, NG_VALIDATORS, @@ -28,7 +39,7 @@ import { Observable } from 'rxjs'; import { filter, map, mergeMap, share, tap } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; import { EntityType } from '@shared/models/entity-type.models'; -import { BaseData } from '@shared/models/base-data'; +import { BaseData, getEntityDisplayName } from '@shared/models/base-data'; import { EntityId } from '@shared/models/id/entity-id'; import { EntityService } from '@core/http/entity.service'; import { MatAutocomplete } from '@angular/material/autocomplete'; @@ -93,6 +104,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan } @Input() + @coerceBoolean() disabled: boolean; @Input() @@ -109,6 +121,17 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan @coerceBoolean() inlineField: boolean; + @Input() + @coerceBoolean() + allowCreateNew: boolean; + + @Input() + @coerceBoolean() + useEntityDisplayName = false; + + @Output() + createNew = new EventEmitter(); + @ViewChild('entityInput') entityInput: ElementRef; @ViewChild('entityAutocomplete') matAutocomplete: MatAutocomplete; @ViewChild('chipList', {static: true}) chipList: MatChipGrid; @@ -136,6 +159,11 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan this.entityListFormGroup.get('entities').updateValueAndValidity(); } + createNewEntity($event: Event, searchText?: string) { + $event.stopPropagation(); + this.createNew.emit(searchText); + } + registerOnChange(fn: any): void { this.propagateChange = fn; } @@ -201,6 +229,9 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan this.modelValue = null; } this.dirty = true; + if (this.entityInput) { + this.entityInput.nativeElement.value = ''; + } } validate(): ValidationErrors | null { @@ -250,7 +281,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan } public displayEntityFn(entity?: BaseData): string | undefined { - return entity ? entity.name : undefined; + return entity ? (this.useEntityDisplayName ? getEntityDisplayName(entity) : entity.name) : undefined; } private fetchEntities(searchText?: string): Observable>> { diff --git a/ui-ngx/src/app/shared/components/entity/entity-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-select.component.html index 2b07af9e08..c0b4f1aafb 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-select.component.html @@ -33,6 +33,7 @@ [appearance]="appearance" [required]="required" [entityType]="modelValue.entityType" + [useEntityDisplayName]="useEntityDisplayName" formControlName="entityId">
    diff --git a/ui-ngx/src/app/shared/components/entity/entity-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-select.component.ts index 01b1f03388..5e75426952 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-select.component.ts @@ -62,6 +62,10 @@ export class EntitySelectComponent implements ControlValueAccessor, OnInit, Afte @Input() appearance: MatFormFieldAppearance = 'fill'; + @Input() + @coerceBoolean() + useEntityDisplayName = false; + displayEntityTypeSelect: boolean; AliasEntityType = AliasEntityType; diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html index 6635921d7f..fb2888c636 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html @@ -47,7 +47,7 @@ (optionSelected)="selected($event)" [displayWith]="displayEntitySubtypeFn"> - +
    diff --git a/ui-ngx/src/app/shared/components/file-input.component.html b/ui-ngx/src/app/shared/components/file-input.component.html index 105257dd24..1f46ea1e34 100644 --- a/ui-ngx/src/app/shared/components/file-input.component.html +++ b/ui-ngx/src/app/shared/components/file-input.component.html @@ -39,11 +39,13 @@ [flow]="flow.flowJs">
    cloud_upload - {{ dropLabel }} - +
    + {{ dropLabel }} + +
    diff --git a/ui-ngx/src/app/shared/components/file-input.component.ts b/ui-ngx/src/app/shared/components/file-input.component.ts index bbe68bb9c6..6960db73dd 100644 --- a/ui-ngx/src/app/shared/components/file-input.component.ts +++ b/ui-ngx/src/app/shared/components/file-input.component.ts @@ -129,10 +129,15 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, @Output() fileNameChanged = new EventEmitter(); + @Output() + mediaTypeChanged = new EventEmitter(); + fileName: string | string[]; fileContent: any; files: File[]; + mediaType: string; + @ViewChild('flow', {static: true}) flow: FlowDirective; @@ -180,6 +185,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, this.fileContent = files[0].fileContent; this.fileName = files[0].fileName; this.files = files[0].files; + this.mediaType = files[0].mediaType; this.updateModel(); } else if (files.length > 1) { this.fileContent = files.map(content => content.fileContent); @@ -203,6 +209,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, let fileName = null; let fileContent = null; let files = null; + let mediaType = null; if (reader.readyState === reader.DONE) { if (!this.workFromFileObj) { fileContent = reader.result; @@ -211,16 +218,18 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, fileContent = this.contentConvertFunction(fileContent); } fileName = fileContent ? file.name : null; + mediaType = file?.file?.type || null; } } else if (file.name || file.file){ files = file.file; fileName = file.name; + mediaType = file.file.type || null; } } - resolve({fileContent, fileName, files}); + resolve({fileContent, fileName, files, mediaType}); }; reader.onerror = () => { - resolve({fileContent: null, fileName: null, files: null}); + resolve({fileContent: null, fileName: null, files: null, mediaType: null}); }; if (this.readAsBinary) { reader.readAsBinaryString(file.file); @@ -283,6 +292,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, this.propagateChange(this.files); } else { this.propagateChange(this.fileContent); + this.mediaTypeChanged.emit(this.mediaType); this.fileNameChanged.emit(this.fileName); } } diff --git a/ui-ngx/src/app/shared/components/phone-input.component.html b/ui-ngx/src/app/shared/components/phone-input.component.html index 6bf61ae4b8..ff5d5f4853 100644 --- a/ui-ngx/src/app/shared/components/phone-input.component.html +++ b/ui-ngx/src/app/shared/components/phone-input.component.html @@ -27,7 +27,7 @@
    - + {{ label }} - + - {{ 'phone-input.phone-input-required' | translate }} + {{ requiredErrorText }} - {{ 'phone-input.phone-input-validation' | translate }} + {{ validationErrorText }}
    diff --git a/ui-ngx/src/app/shared/components/phone-input.component.ts b/ui-ngx/src/app/shared/components/phone-input.component.ts index bd1124f97a..5d6d0b21a7 100644 --- a/ui-ngx/src/app/shared/components/phone-input.component.ts +++ b/ui-ngx/src/app/shared/components/phone-input.component.ts @@ -77,6 +77,15 @@ export class PhoneInputComponent implements OnInit, ControlValueAccessor, Valida @Input() label = this.translate.instant('phone-input.phone-input-label'); + @Input() + hint = 'phone-input.phone-input-hint'; + + @Input() + requiredErrorText = this.translate.instant('phone-input.phone-input-required'); + + @Input() + validationErrorText = this.translate.instant('phone-input.phone-input-validation'); + get showFlagSelect(): boolean { return this.enableFlagsSelect && !this.isLegacy; } diff --git a/ui-ngx/src/app/shared/components/string-autocomplete.component.html b/ui-ngx/src/app/shared/components/string-autocomplete.component.html index 8eb22707f9..31966b932d 100644 --- a/ui-ngx/src/app/shared/components/string-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/string-autocomplete.component.html @@ -24,12 +24,12 @@ [matAutocomplete]="optionsAutocomplete"> - warning - + {{errorText}} this.updateView(value)), @@ -152,7 +156,7 @@ export class StringAutocompleteComponent implements ControlValueAccessor, OnInit updateView(value: string) { this.searchText = value ? value : ''; if (this.modelValue !== value) { - this.modelValue = value; + this.modelValue = value?.trim(); this.propagateChange(this.modelValue); } } diff --git a/ui-ngx/src/app/shared/components/time-unit-input.component.html b/ui-ngx/src/app/shared/components/time-unit-input.component.html index d5f6319576..53a444895b 100644 --- a/ui-ngx/src/app/shared/components/time-unit-input.component.html +++ b/ui-ngx/src/app/shared/components/time-unit-input.component.html @@ -18,7 +18,7 @@
    + subscriptSizing="dynamic"> @if (labelText && !inlineField) { {{ labelText }} } @@ -41,7 +41,9 @@ {{ hasError }} - @if (!inlineField) { diff --git a/ui-ngx/src/app/shared/components/time-unit-input.component.ts b/ui-ngx/src/app/shared/components/time-unit-input.component.ts index 44f1be514a..678d9e9f00 100644 --- a/ui-ngx/src/app/shared/components/time-unit-input.component.ts +++ b/ui-ngx/src/app/shared/components/time-unit-input.component.ts @@ -178,7 +178,7 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, this.timeInputForm.disable({emitEvent: false}); } else { this.timeInputForm.enable({emitEvent: false}); - if(this.timeInputForm.invalid) { + if(!this.timeInputForm.valid) { setTimeout(() => this.updatedModel(this.timeInputForm.value, true)) } } @@ -200,7 +200,7 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, } validate(): ValidationErrors | null { - return this.timeInputForm.valid ? null : { + return this.timeInputForm.disabled || this.timeInputForm.valid ? null : { timeInput: false }; } diff --git a/ui-ngx/src/app/shared/components/time/datapoints-limit.component.ts b/ui-ngx/src/app/shared/components/time/datapoints-limit.component.ts index 02470cc949..e2799bd0fc 100644 --- a/ui-ngx/src/app/shared/components/time/datapoints-limit.component.ts +++ b/ui-ngx/src/app/shared/components/time/datapoints-limit.component.ts @@ -29,6 +29,7 @@ import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { TimeService } from '@core/services/time.service'; import { takeUntil } from 'rxjs/operators'; import { Subject } from 'rxjs'; +import { isDefined } from '@core/utils'; @Component({ selector: 'tb-datapoints-limit', @@ -69,7 +70,11 @@ export class DatapointsLimitComponent implements ControlValueAccessor, Validator @Input() disabled: boolean; - private propagateChange = (v: any) => { }; + private propagateChangeValue: any; + + private propagateChange = (v: any) => { + this.propagateChangeValue = v; + }; private destroy$ = new Subject(); @@ -79,6 +84,9 @@ export class DatapointsLimitComponent implements ControlValueAccessor, Validator registerOnChange(fn: any): void { this.propagateChange = fn; + if (isDefined(this.propagateChangeValue)) { + this.propagateChange(this.propagateChangeValue); + } } registerOnTouched(fn: any): void { @@ -115,19 +123,20 @@ export class DatapointsLimitComponent implements ControlValueAccessor, Validator } } - private checkLimit(limit?: number): number { - if (!limit || limit < this.minDatapointsLimit()) { - return this.minDatapointsLimit(); + writeValue(value: number | null): void { + this.modelValue = value; + let limit = this.modelValue; + if (!limit) { + limit = Math.ceil(this.maxDatapointsLimit() / 2); + } else if (limit < this.minDatapointsLimit()) { + limit = this.minDatapointsLimit(); } else if (limit > this.maxDatapointsLimit()) { - return this.maxDatapointsLimit(); + limit = this.maxDatapointsLimit(); } - return limit; - } - writeValue(value: number | null): void { - this.modelValue = this.checkLimit(value); + this.updateView(limit); this.datapointsLimitFormGroup.patchValue( - { limit: this.modelValue }, {emitEvent: false} + { limit: limit }, {emitEvent: false} ); } diff --git a/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.ts b/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.ts index 42e04cfc46..af3d51de69 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.ts @@ -419,9 +419,10 @@ export class TimewindowConfigDialogComponent extends PageComponent implements On const timewindowFormValue = this.timewindowForm.getRawValue(); const realtimeDisableCustomInterval = timewindowFormValue.realtime.disableCustomInterval; const historyDisableCustomInterval = timewindowFormValue.history.disableCustomInterval; - updateFormValuesOnTimewindowTypeChange(selectedTab, this.quickIntervalOnly, this.timewindowForm, + updateFormValuesOnTimewindowTypeChange(selectedTab, this.timewindowForm, realtimeDisableCustomInterval, historyDisableCustomInterval, - timewindowFormValue.realtime.advancedParams, timewindowFormValue.history.advancedParams); + timewindowFormValue.realtime.advancedParams, timewindowFormValue.history.advancedParams, + this.realtimeTimewindowOptions, this.historyTimewindowOptions); this.timewindowForm.patchValue({ hideAggregation: timewindowFormValue.hideAggregation, hideAggInterval: timewindowFormValue.hideAggInterval, diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts index 2f0a1aa603..c306ce4a49 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts @@ -401,9 +401,10 @@ export class TimewindowPanelComponent extends PageComponent implements OnInit, O } private onTimewindowTypeChange(selectedTab: TimewindowType) { - updateFormValuesOnTimewindowTypeChange(selectedTab, this.quickIntervalOnly, this.timewindowForm, + updateFormValuesOnTimewindowTypeChange(selectedTab, this.timewindowForm, this.realtimeDisableCustomInterval, this.historyDisableCustomInterval, - this.realtimeAdvancedParams, this.historyAdvancedParams); + this.realtimeAdvancedParams, this.historyAdvancedParams, + this.realtimeTimewindowOptions, this.historyTimewindowOptions); } update() { diff --git a/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html index 4515797a85..665b4d63ad 100644 --- a/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html @@ -15,7 +15,10 @@ limitations under the License. --> - + {{ 'version-control.branch' | translate }} , private translate: TranslateService, private dashboardService: DashboardService, @@ -177,9 +175,7 @@ export class ImportExportService { public exportCalculatedField(calculatedFieldId: string): void { this.calculatedFieldsService.getCalculatedFieldById(calculatedFieldId).subscribe({ next: (calculatedField) => { - let name = calculatedField.name; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareCalculatedFieldExport(calculatedField), name); + this.exportToPc(this.prepareCalculatedFieldExport(calculatedField), calculatedField.name, true); }, error: (e) => { this.handleExportError(e, 'calculated-fields.export-failed-error'); @@ -200,9 +196,7 @@ export class ImportExportService { this.updateUserSettingsIncludeResourcesIfNeeded(includeResources, result.include, 'includeResourcesInExportDashboard'); this.dashboardService.exportDashboard(dashboardId, result.include).subscribe({ next: (dashboard) => { - let name = dashboard.title; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareDashboardExport(dashboard), name); + this.exportToPc(this.prepareDashboardExport(dashboard), dashboard.title, true); }, error: (e) => { this.handleExportError(e, 'dashboard.export-failed-error'); @@ -261,9 +255,8 @@ export class ImportExportService { widgetTitle: string, breakpoint: BreakpointId) { const widgetItem = this.itembuffer.prepareWidgetItem(dashboard, sourceState, sourceLayout, widget, breakpoint); const widgetDefaultName = this.widgetService.getWidgetInfoFromCache(widget.typeFullFqn).widgetName; - let fileName = widgetDefaultName + (isNotEmptyStr(widgetTitle) ? `_${widgetTitle}` : ''); - fileName = fileName.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareExport(widgetItem), fileName); + const fileName = widgetDefaultName + (isNotEmptyStr(widgetTitle) ? `_${widgetTitle}` : ''); + this.exportToPc(this.prepareExport(widgetItem), fileName, true); } public importWidget(dashboard: Dashboard, targetState: string, @@ -360,9 +353,7 @@ export class ImportExportService { this.updateUserSettingsIncludeResourcesIfNeeded(includeResources, result.include, 'includeResourcesInExportWidgetTypes'); this.widgetService.exportWidgetType(widgetTypeId, result.include).subscribe({ next: (widgetTypeDetails) => { - let name = widgetTypeDetails.name; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareExport(widgetTypeDetails), name); + this.exportToPc(this.prepareExport(widgetTypeDetails), widgetTypeDetails.name, true); }, error: (e) => { this.handleExportError(e, 'widget-type.export-failed-error'); @@ -440,7 +431,7 @@ export class ImportExportService { public exportEntity(entityData: VersionedEntity): void { const id = (entityData as EntityInfoData).id ?? (entityData as RuleChainMetaData).ruleChainId; let fileName = (entityData as EntityInfoData).name; - let preparedData; + let preparedData: any; switch (id.entityType) { case EntityType.DEVICE_PROFILE: case EntityType.ASSET_PROFILE: @@ -511,9 +502,7 @@ export class ImportExportService { for (const widgetTypeDetails of widgetTypesDetails) { widgetsBundleItem.widgetTypes.push(this.prepareExport(widgetTypeDetails)); } - let name = widgetsBundle.title; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(widgetsBundleItem, name); + this.exportToPc(widgetsBundleItem, widgetsBundle.title, true); }, error: (e) => { this.handleExportError(e, 'widgets-bundle.export-failed-error'); @@ -528,9 +517,7 @@ export class ImportExportService { widgetsBundle: this.prepareExport(widgetsBundle), widgetTypeFqns }; - let name = widgetsBundle.title; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(widgetsBundleItem, name); + this.exportToPc(widgetsBundleItem, widgetsBundle.title, true); }, error: (e) => { this.handleExportError(e, 'widgets-bundle.export-failed-error'); @@ -662,11 +649,9 @@ export class ImportExportService { private onRuleChainExported() { return { next: (ruleChainExport: RuleChainImport) => { - let name = ruleChainExport.ruleChain.name; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(ruleChainExport, name); + this.exportToPc(ruleChainExport, ruleChainExport.ruleChain.name, true); }, - error: (e) => { + error: (e: any) => { this.handleExportError(e, 'rulechain.export-failed-error'); } }; @@ -747,9 +732,7 @@ export class ImportExportService { public exportDeviceProfile(deviceProfileId: string) { this.deviceProfileService.exportDeviceProfile(deviceProfileId).subscribe({ next: (deviceProfile) => { - let name = deviceProfile.name; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareProfileExport(deviceProfile), name); + this.exportToPc(this.prepareProfileExport(deviceProfile), deviceProfile.name, true); }, error: (e) => { this.handleExportError(e, 'device-profile.export-failed-error'); @@ -776,9 +759,7 @@ export class ImportExportService { public exportAssetProfile(assetProfileId: string) { this.assetProfileService.exportAssetProfile(assetProfileId).subscribe({ next: (assetProfile) => { - let name = assetProfile.name; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareProfileExport(assetProfile), name); + this.exportToPc(this.prepareProfileExport(assetProfile), assetProfile.name, true); }, error: (e) => { this.handleExportError(e, 'asset-profile.export-failed-error'); @@ -805,9 +786,7 @@ export class ImportExportService { public exportTenantProfile(tenantProfileId: string) { this.tenantProfileService.getTenantProfile(tenantProfileId).subscribe({ next: (tenantProfile) => { - let name = tenantProfile.name; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareProfileExport(tenantProfile), name); + this.exportToPc(this.prepareProfileExport(tenantProfile), tenantProfile.name, true); }, error: (e) => { this.handleExportError(e, 'tenant-profile.export-failed-error'); @@ -842,7 +821,7 @@ export class ImportExportService { return cellData; } - public exportCsv(data: {[key: string]: any}[], filename: string) { + public exportCsv(data: {[key: string]: any}[], filename: string, normalizeFileName = false) { let colsHead: string; let colsData: string; if (data && data.length) { @@ -857,18 +836,18 @@ export class ImportExportService { colsData = ''; } const csvData = `${colsHead}\n${colsData}`; - this.downloadFile(csvData, filename, CSV_TYPE); + this.downloadFile(csvData, filename, CSV_TYPE, normalizeFileName); } - public exportText(data: string | Array, filename: string) { + public exportText(data: string | Array, filename: string, normalizeFileName = false) { let content = data; if (Array.isArray(data)) { content = data.join('\n'); } - this.downloadFile(content, filename, TEXT_TYPE); + this.downloadFile(content, filename, TEXT_TYPE, normalizeFileName); } - public exportJSZip(data: object, filename: string): Observable { + public exportJSZip(data: object, filename: string, normalizeFileName = false): Observable { const exportJsSubjectSubject = new Subject(); import('jszip').then((JSZip) => { try { @@ -880,9 +859,9 @@ export class ImportExportService { } } jsZip.generateAsync({type: 'blob'}).then(content => { - this.downloadFile(content, filename, ZIP_TYPE); + this.downloadFile(content, filename, ZIP_TYPE, normalizeFileName); exportJsSubjectSubject.next(null); - }).catch(e => { + }).catch((e: any) => { exportJsSubjectSubject.error(e); }); } catch (e) { @@ -1180,42 +1159,40 @@ export class ImportExportService { )); } - private exportToPc(data: any, filename: string) { + private exportToPc(data: any, filename: string, normalizeFileName = false) { if (!data) { console.error('No data'); return; } - this.exportJson(data, filename); + this.exportJson(data, filename, normalizeFileName); } - public exportJson(data: any, filename: string) { + public exportJson(data: any, filename: string, normalizeFileName = false) { if (isObject(data)) { data = JSON.stringify(data, null, 2); } - this.downloadFile(data, filename, JSON_TYPE); + this.downloadFile(data, filename, JSON_TYPE, normalizeFileName); } - private downloadFile(data: any, filename: string, fileType: FileType) { - if (!filename) { - filename = 'download'; + private prepareFilename(filename: string, extension: string, normalizeFileName = false): string { + if (normalizeFileName) { + filename = filename.toLowerCase().replace(/\s/g, '_'); } - filename += '.' + fileType.extension; + filename = filename.replace(/[\\/<>:"|?*\s]/g, '_'); + return `${filename}.${extension}`; + } + + private downloadFile(data: any, filename = 'download', fileType: FileType, normalizeFileName: boolean) { + filename = this.prepareFilename(filename, fileType.extension, normalizeFileName); const blob = new Blob([data], {type: fileType.mimeType}); - // @ts-ignore - if (this.window.navigator && this.window.navigator.msSaveOrOpenBlob) { - // @ts-ignore - this.window.navigator.msSaveOrOpenBlob(blob, filename); - } else { - const e = this.document.createEvent('MouseEvents'); - const a = this.document.createElement('a'); - a.download = filename; - a.href = URL.createObjectURL(blob); - a.dataset.downloadurl = [fileType.mimeType, a.download, a.href].join(':'); - // @ts-ignore - e.initEvent('click', true, false, this.window, - 0, 0, 0, 0, 0, false, false, false, false, 0, null); - a.dispatchEvent(e); - } + const url = URL.createObjectURL(blob); + + const a = this.document.createElement('a'); + a.href = url; + a.download = filename; + a.dataset.downloadurl = [fileType.mimeType, a.download, a.href].join(':'); + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); } private prepareDashboardExport(dashboard: Dashboard): Dashboard { diff --git a/ui-ngx/src/app/shared/models/ai-model.models.ts b/ui-ngx/src/app/shared/models/ai-model.models.ts index f3161263b7..d3b70f9a74 100644 --- a/ui-ngx/src/app/shared/models/ai-model.models.ts +++ b/ui-ngx/src/app/shared/models/ai-model.models.ts @@ -34,6 +34,13 @@ export interface AiModel extends Omit, 'label'>, HasTenantId region?: string; accessKeyId?: string; secretAccessKey?: string; + baseUrl?: string; + auth?: { + type: AuthenticationType; + username?: string; + password?: string; + token?: string + } }; modelId: string; temperature?: number; @@ -42,6 +49,7 @@ export interface AiModel extends Omit, 'label'>, HasTenantId frequencyPenalty?: number; presencePenalty?: number; maxOutputTokens?: number; + contextLength?: number; } } @@ -57,7 +65,8 @@ export enum AiProvider { MISTRAL_AI = 'MISTRAL_AI', ANTHROPIC = 'ANTHROPIC', AMAZON_BEDROCK = 'AMAZON_BEDROCK', - GITHUB_MODELS = 'GITHUB_MODELS' + GITHUB_MODELS = 'GITHUB_MODELS', + OLLAMA = 'OLLAMA' } export const AiProviderTranslations = new Map( @@ -69,7 +78,8 @@ export const AiProviderTranslations = new Map( [AiProvider.MISTRAL_AI , 'ai-models.ai-providers.mistral-ai'], [AiProvider.ANTHROPIC , 'ai-models.ai-providers.anthropic'], [AiProvider.AMAZON_BEDROCK , 'ai-models.ai-providers.amazon-bedrock'], - [AiProvider.GITHUB_MODELS , 'ai-models.ai-providers.github-models'] + [AiProvider.GITHUB_MODELS , 'ai-models.ai-providers.github-models'], + [AiProvider.OLLAMA , 'ai-models.ai-providers.ollama'] ] ); @@ -84,10 +94,11 @@ export const ProviderFieldsAllList = [ 'serviceVersion', 'region', 'accessKeyId', - 'secretAccessKey' + 'secretAccessKey', + 'baseUrl' ]; -export const ModelFieldsAllList = ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens']; +export const ModelFieldsAllList = ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens', 'contextLength']; export const AiModelMap = new Map([ [ @@ -99,13 +110,16 @@ export const AiModelMap = new Map( + [ + [AlarmRuleScheduleType.ANY_TIME, 'alarm-rule.schedule.any-time'], + [AlarmRuleScheduleType.SPECIFIC_TIME, 'alarm-rule.schedule.specific-time'], + [AlarmRuleScheduleType.CUSTOM, 'alarm-rule.schedule.custom'] + ] +); + +export enum AlarmRuleConditionType { + SIMPLE = 'SIMPLE', + DURATION = 'DURATION', + REPEATING = 'REPEATING' +} + +export const AlarmRuleConditionTypeTranslationMap = new Map( + [ + [AlarmRuleConditionType.SIMPLE, 'alarm-rule.conditions.simple'], + [AlarmRuleConditionType.DURATION, 'alarm-rule.conditions.duration'], + [AlarmRuleConditionType.REPEATING, 'alarm-rule.conditions.repeating'] + ] +); + +export enum AlarmRuleExpressionType { + SIMPLE = 'SIMPLE', + TBEL = 'TBEL', +} + +export const FilterPredicateTypeTranslationMap = new Map( + [ + [FilterPredicateType.STRING, 'alarm-rule.filter-predicate-type.string'], + [FilterPredicateType.NUMERIC, 'alarm-rule.filter-predicate-type.numeric'], + [FilterPredicateType.BOOLEAN, 'alarm-rule.filter-predicate-type.boolean'], + [FilterPredicateType.COMPLEX, 'alarm-rule.filter-predicate-type.complex'] + ] +); + +export interface AlarmRule { + condition: AlarmRuleCondition; + alarmDetails?: string; + dashboardId?: DashboardId; +} + +export interface AlarmRuleCondition { + type: AlarmRuleConditionType; + expression: AlarmRuleExpression; + schedule?: AlarmRuleSchedule; + unit?: TimeUnit; + value?: AlarmRuleValue; + count?: AlarmRuleValue; +} + +export interface AlarmRuleExpression { + type: AlarmRuleExpressionType; + expression?: string; + filters?: Array; + operation?: ComplexOperation; +} + +export interface AlarmRuleSchedule { + staticValue?: { + type?: AlarmRuleScheduleType; + timezone?: string; + daysOfWeek?: number[]; + startsOn?: number; + endsOn?: number; + items?: CustomTimeSchedulerItem[]; + }; + dynamicValueArgument?: string; +} + +export interface AlarmRuleFilter { + argument: string; + valueType: EntityKeyValueType; + operation: ComplexOperation; + predicates: AlarmRuleFilterPredicate[]; +} + +export interface AlarmRulePredicateInfo { + keyFilterPredicate: AlarmRuleFilterPredicate; +} + +export type AlarmRuleFilterPredicate = StringAlarmRuleFilterPredicate | + NumericAlarmRuleFilterPredicate | + BooleanAlarmRuleFilterPredicate | + ComplexAlarmRuleFilterPredicate; + +export interface AlarmRuleValue { + dynamicValueArgument?: string; + staticValue?: T +} + +export interface StringAlarmRuleFilterPredicate { + type: FilterPredicateType.STRING; + operation: StringOperation; + value: AlarmRuleValue; + ignoreCase: boolean; +} + +export interface NumericAlarmRuleFilterPredicate { + type: FilterPredicateType.NUMERIC; + operation: NumericOperation; + value: AlarmRuleValue; +} + +export interface BooleanAlarmRuleFilterPredicate { + type: FilterPredicateType.BOOLEAN; + operation: BooleanOperation; + value: AlarmRuleValue; +} + +export interface BaseComplexFilterPredicate { + type: FilterPredicateType.COMPLEX; + operation: ComplexOperation; + predicates: Array; +} + +export type ComplexAlarmRuleFilterPredicate = BaseComplexFilterPredicate; diff --git a/ui-ngx/src/app/shared/models/alarm.models.ts b/ui-ngx/src/app/shared/models/alarm.models.ts index bcf0d7b75d..590e866465 100644 --- a/ui-ngx/src/app/shared/models/alarm.models.ts +++ b/ui-ngx/src/app/shared/models/alarm.models.ts @@ -140,6 +140,7 @@ export interface AlarmCommentInfo extends AlarmComment { export interface AlarmInfo extends Alarm { originatorName: string; originatorLabel: string; + originatorDisplayName?: string; assignee: AlarmAssignee; } @@ -172,6 +173,7 @@ export const simulatedAlarm: AlarmInfo = { clearTs: 0, assignTs: 0, originatorName: 'Simulated', + originatorDisplayName: 'Simulated', originatorLabel: 'Simulated', assignee: { firstName: '', @@ -242,6 +244,11 @@ export const alarmFields: {[fieldName: string]: AlarmField} = { value: 'originatorName', name: 'alarm.originator' }, + originatorDisplayName: { + keyName: 'originatorDisplayName', + value: 'originatorDisplayName', + name: 'alarm.originator' + }, originatorLabel: { keyName: 'originatorLabel', value: 'originatorLabel', diff --git a/ui-ngx/src/app/shared/models/authority.enum.ts b/ui-ngx/src/app/shared/models/authority.enum.ts index 8b18beb887..d91ecf777a 100644 --- a/ui-ngx/src/app/shared/models/authority.enum.ts +++ b/ui-ngx/src/app/shared/models/authority.enum.ts @@ -20,5 +20,6 @@ export enum Authority { CUSTOMER_USER = 'CUSTOMER_USER', REFRESH_TOKEN = 'REFRESH_TOKEN', ANONYMOUS = 'ANONYMOUS', - PRE_VERIFICATION_TOKEN = 'PRE_VERIFICATION_TOKEN' + PRE_VERIFICATION_TOKEN = 'PRE_VERIFICATION_TOKEN', + MFA_CONFIGURATION_TOKEN = 'MFA_CONFIGURATION_TOKEN' } diff --git a/ui-ngx/src/app/shared/models/base-data.ts b/ui-ngx/src/app/shared/models/base-data.ts index 972fa0b5a0..d9dc4b070d 100644 --- a/ui-ngx/src/app/shared/models/base-data.ts +++ b/ui-ngx/src/app/shared/models/base-data.ts @@ -16,7 +16,9 @@ import { EntityId } from '@shared/models/id/entity-id'; import { HasUUID } from '@shared/models/id/has-uuid'; -import { isDefinedAndNotNull } from '@core/utils'; +import { isDefinedAndNotNull, isNotEmptyStr } from '@core/utils'; +import { EntityType } from '@shared/models/entity-type.models'; +import { User } from '@shared/models/user.model'; export declare type HasId = EntityId | HasUUID; @@ -49,3 +51,12 @@ export function hasIdEquals(id1: HasId, id2: HasId): boolean { return id1 === id2; } } + +export function getEntityDisplayName(entity: BaseData): string { + if (entity?.id?.entityType === EntityType.USER) { + const user = entity as User; + const userName = (user?.firstName ?? '') + " " + (user?.lastName ?? ''); + return isNotEmptyStr(userName) ? userName.trim() : entity?.name; + } + return isNotEmptyStr(entity?.label) ? entity.label : entity?.name; +} diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts index a86943c86e..1b43700ebe 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -30,40 +30,169 @@ import { endGroupHighlightRule } from '@shared/models/ace/ace.models'; import { EntitySearchDirection } from '@shared/models/relation.models'; +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; +import { AlarmRule } from "@shared/models/alarm-rule.models"; +import { AlarmSeverity } from "@shared/models/alarm.models"; -export interface CalculatedField extends Omit, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity { - configuration: CalculatedFieldConfiguration; - type: CalculatedFieldType; +interface BaseCalculatedField extends Omit, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity { entityId: EntityId; } +export interface CalculatedFieldSimple extends BaseCalculatedField { + type: CalculatedFieldType.SIMPLE; + configuration: CalculatedFieldSimpleConfiguration; +} + +export interface CalculatedFieldScript extends BaseCalculatedField { + type: CalculatedFieldType.SCRIPT; + configuration: CalculatedFieldScriptConfiguration; +} + +export interface CalculatedFieldGeofencing extends BaseCalculatedField { + type: CalculatedFieldType.GEOFENCING; + configuration: CalculatedFieldGeofencingConfiguration; +} + +export interface CalculatedFieldPropagation extends BaseCalculatedField { + type: CalculatedFieldType.PROPAGATION; + configuration: CalculatedFieldPropagationConfiguration; +} + +export interface CalculatedFieldAlarmRule extends BaseCalculatedField { + type: CalculatedFieldType.ALARM; + configuration: CalculatedFieldAlarmRuleConfiguration; +} + +export type CalculatedField = + | CalculatedFieldSimple + | CalculatedFieldScript + | CalculatedFieldGeofencing + | CalculatedFieldPropagation + | CalculatedFieldAlarmRule; + export enum CalculatedFieldType { SIMPLE = 'SIMPLE', SCRIPT = 'SCRIPT', - GEOFENCING = 'GEOFENCING' + GEOFENCING = 'GEOFENCING', + PROPAGATION = 'PROPAGATION', + RELATED_ENTITIES_AGGREGATION = 'RELATED_ENTITIES_AGGREGATION', + ALARM = 'ALARM', } -export const CalculatedFieldTypeTranslations = new Map( +interface CalculatedFieldTypeTranslate { + name: string; + hint?: string; +} + +export const CalculatedFieldTypeTranslations = new Map( [ - [CalculatedFieldType.SIMPLE, 'calculated-fields.type.simple'], - [CalculatedFieldType.SCRIPT, 'calculated-fields.type.script'], - [CalculatedFieldType.GEOFENCING, 'calculated-fields.type.geofencing'], + [CalculatedFieldType.SIMPLE, { + name: 'calculated-fields.type.simple' + }], + [CalculatedFieldType.SCRIPT, { + name: 'calculated-fields.type.script' + }], + [CalculatedFieldType.GEOFENCING, { + name: 'calculated-fields.type.geofencing' + }], + [CalculatedFieldType.PROPAGATION, { + name: 'calculated-fields.type.propagation' + }], + [CalculatedFieldType.RELATED_ENTITIES_AGGREGATION, { + name: 'calculated-fields.type.related-entities-aggregation', + hint: 'calculated-fields.type.related-entities-aggregation-hint' + }], ] ) -export interface CalculatedFieldConfiguration { - type: CalculatedFieldType; - expression?: string; - arguments?: Record; - zoneGroups?: Record; +export type CalculatedFieldConfiguration = + | CalculatedFieldSimpleConfiguration + | CalculatedFieldScriptConfiguration + | CalculatedFieldGeofencingConfiguration + | CalculatedFieldPropagationConfiguration + | CalculatedFieldRelatedAggregationConfiguration + | CalculatedFieldAlarmRuleConfiguration; + +export interface CalculatedFieldSimpleConfiguration { + type: CalculatedFieldType.SIMPLE; + expression: string; + arguments: Record; + useLatestTs: boolean; + output: CalculatedFieldSimpleOutput; +} + +export interface CalculatedFieldScriptConfiguration { + type: CalculatedFieldType.SCRIPT; + expression: string; + arguments: Record; + output: CalculatedFieldOutput; +} + +export interface CalculatedFieldGeofencingConfiguration { + type: CalculatedFieldType.GEOFENCING; + zoneGroups: Record; + scheduledUpdateEnabled: boolean; + scheduledUpdateInterval?: number; + output: CalculatedFieldOutput; +} + +export interface CalculatedFieldRelatedAggregationConfiguration { + type: CalculatedFieldType.RELATED_ENTITIES_AGGREGATION; + relation: RelationPathLevel; + arguments: Record; + metrics: Record; + deduplicationIntervalInSec: number; scheduledUpdateInterval?: number; + useLatestTs: boolean; + output: CalculatedFieldOutput & { decimalsByDefault?: number; }; +} + +interface BasePropagationConfiguration { + type: CalculatedFieldType.PROPAGATION; + relation: RelationPathLevel; + arguments: Record; output: CalculatedFieldOutput; } -export interface CalculatedFieldOutput { - type: OutputType; +interface CalculatedFieldAlarmRuleConfiguration { + type: CalculatedFieldType.ALARM; + arguments: Record; + createRules: Record; + clearRule?: AlarmRule; + propagate: boolean; + propagateToOwner: boolean; + propagateToTenant: boolean; + propagateRelationTypes?: Array; +} + +export interface PropagationWithNoExpression extends BasePropagationConfiguration { + applyExpressionToResolvedArguments: false; +} + +export interface PropagationWithExpression extends BasePropagationConfiguration { + applyExpressionToResolvedArguments: true; + expression: string; +} + +export type CalculatedFieldPropagationConfiguration = + | PropagationWithNoExpression + | PropagationWithExpression; + +export type CalculatedFieldOutput = + | CalculatedFieldOutputAttribute + | CalculatedFieldOutputTimeSeries; + +export interface CalculatedFieldOutputAttribute { + type: OutputType.Attribute, + scope: AttributeScope; +} + +export interface CalculatedFieldOutputTimeSeries { + type: OutputType.Timeseries; +} + +export type CalculatedFieldSimpleOutput = CalculatedFieldOutput & { name: string; - scope?: AttributeScope; decimalsByDefault?: number; } @@ -73,7 +202,8 @@ export enum ArgumentEntityType { Asset = 'ASSET', Customer = 'CUSTOMER', Tenant = 'TENANT', - RelationQuery = 'RELATION_QUERY', + Owner = 'CURRENT_OWNER', + RelationQuery = 'RELATION_PATH_QUERY', } export const ArgumentEntityTypeTranslations = new Map( @@ -83,6 +213,7 @@ export const ArgumentEntityTypeTranslations = new Map( + [ + [EntitySearchDirection.FROM, 'calculated-fields.direction-down'], + [EntitySearchDirection.TO, 'calculated-fields.direction-up'], + ] +) + +export const PropagationDirectionTranslations = new Map( + [ + [EntitySearchDirection.FROM, 'calculated-fields.direction-down-child'], + [EntitySearchDirection.TO, 'calculated-fields.direction-up-parent'], + ] +) + export enum ArgumentType { Attribute = 'ATTRIBUTE', LatestTelemetry = 'TS_LATEST', @@ -151,26 +296,76 @@ export interface CalculatedFieldArgument { refEntityKey: RefEntityKey; defaultValue?: string; refEntityId?: RefEntityId; + refDynamicSourceConfiguration?: RefDynamicSourceConfiguration; limit?: number; timeWindow?: number; } +export interface RefDynamicSourceConfiguration { + type: ArgumentEntityType.Owner; +} + +export enum AggFunction { + AVG='AVG', + MIN='MIN', + MAX='MAX', + SUM='SUM', + COUNT='COUNT', + COUNT_UNIQUE='COUNT_UNIQUE' +} + +export const AggFunctionTranslations = new Map([ + [AggFunction.AVG, 'calculated-fields.metrics.aggregation-type.avg'], + [AggFunction.MIN, 'calculated-fields.metrics.aggregation-type.min'], + [AggFunction.MAX, 'calculated-fields.metrics.aggregation-type.max'], + [AggFunction.SUM, 'calculated-fields.metrics.aggregation-type.sum'], + [AggFunction.COUNT, 'calculated-fields.metrics.aggregation-type.count'], + [AggFunction.COUNT_UNIQUE, 'calculated-fields.metrics.aggregation-type.count-unique'], +]) + +export interface CalculatedFieldAggMetric { + function: AggFunction; + filter?: string; + input: AggKeyInput | AggFunctionInput; +} + +export interface CalculatedFieldAggMetricValue extends CalculatedFieldAggMetric { + name: string; +} + +export enum AggInputType { + key = 'key', + function = 'function' +} + +export const AggInputTypeTranslations = new Map([ + [AggInputType.key, 'calculated-fields.metrics.value-source-type.key'], + [AggInputType.function, 'calculated-fields.metrics.value-source-type.function'], +]) + +export interface AggKeyInput { + type: AggInputType.key; + key: string; +} + +export interface AggFunctionInput { + type: AggInputType.function; + function: string; +} + export interface CalculatedFieldGeofencing { perimeterKeyName: string; reportStrategy: GeofencingReportStrategy; refEntityId?: RefEntityId; - refDynamicSourceConfiguration: RefDynamicSourceConfiguration; + refDynamicSourceConfiguration: RefDynamicSourceGeofencingConfiguration; createRelationsWithMatchedZones: boolean; relationType: string; direction: EntitySearchDirection; } -export interface RefDynamicSourceConfiguration { - type?: ArgumentEntityType.RelationQuery; - direction: EntitySearchDirection; - relationType: string; - maxLevel: number; - fetchLastLevelOnly?: boolean; +export interface RefDynamicSourceGeofencingConfiguration { + type: ArgumentEntityType.RelationQuery | ArgumentEntityType.Owner; + levels?: Array; } export interface CalculatedFieldGeofencingValue extends CalculatedFieldGeofencing { @@ -237,6 +432,11 @@ export interface CalculatedFieldArgumentValueBase { type: ArgumentType; } +export interface RelationPathLevel { + direction: EntitySearchDirection; + relationType: string; +} + export interface CalculatedFieldAttributeArgumentValue extends CalculatedFieldArgumentValueBase { ts: number; value: ValueType; @@ -701,3 +901,13 @@ export const calculatedFieldDefaultScript = 'return {\n' + ' "temperatureC": (temperatureF - 32) / 1.8\n' + '};' + +export function notEmptyObjectValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value; + if (typeof value === 'object' && value !== null && Object.keys(value).length === 0) { + return {emptyObject: true}; + } + return null; + }; +} diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index f88c6a1216..f935828989 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -94,76 +94,83 @@ export const HelpLinks = { oauth2Apple: 'https://developer.apple.com/sign-in-with-apple/get-started/', oauth2Facebook: 'https://developers.facebook.com/docs/facebook-login/web#logindialog', oauth2Github: 'https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app', - oauth2Google: 'https://developers.google.com/google-ads/api/docs/start', + oauth2Google: 'https://developers.google.com/identity/protocols/oauth2', ruleEngine: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/overview/`, - ruleNodeCheckRelation: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#check-relation-filter-node`, - ruleNodeCheckExistenceFields: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#check-existence-fields-node`, - ruleNodeGpsGeofencingFilter: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#gps-geofencing-filter-node`, - ruleNodeJsFilter: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#script-filter-node`, - ruleNodeJsSwitch: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#switch-node`, - ruleNodeAssetProfileSwitch: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#asset-profile-switch`, - ruleNodeDeviceProfileSwitch: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#device-profile-switch`, - ruleNodeCheckAlarmStatus: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#check-alarm-status`, - ruleNodeMessageTypeFilter: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#message-type-filter-node`, - ruleNodeMessageTypeSwitch: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#message-type-switch-node`, - ruleNodeOriginatorTypeFilter: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#originator-type-filter-node`, - ruleNodeOriginatorTypeSwitch: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#originator-type-switch-node`, - ruleNodeOriginatorAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#originator-attributes`, - ruleNodeOriginatorFields: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#originator-fields`, - ruleNodeOriginatorTelemetry: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#originator-telemetry`, - ruleNodeCustomerAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#customer-attributes`, - ruleNodeCustomerDetails: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#customer-details`, - ruleNodeFetchDeviceCredentials: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#fetch-device-credentials`, - ruleNodeDeviceAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#device-attributes`, - ruleNodeRelatedAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#related-attributes`, - ruleNodeTenantAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#tenant-attributes`, - ruleNodeTenantDetails: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#tenant-details`, - ruleNodeChangeOriginator: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/transformation-nodes/#change-originator`, - ruleNodeTransformMsg: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/transformation-nodes/#script-transformation-node`, - ruleNodeMsgToEmail: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/transformation-nodes/#to-email-node`, - ruleNodeAssignToCustomer: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#assign-to-customer-node`, - ruleNodeUnassignFromCustomer: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#unassign-from-customer-node`, - ruleNodeCalculatedFields: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#calculated-fields-node`, - ruleNodeClearAlarm: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#clear-alarm-node`, - ruleNodeCreateAlarm: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#create-alarm-node`, - ruleNodeCopyToView: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#copy-to-view-node`, - ruleNodeCreateRelation: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#create-relation-node`, - ruleNodeDeleteRelation: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#delete-relation-node`, - ruleNodeDeviceState: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#device-state-node`, - ruleNodeMessageCount: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#message-count-node`, - ruleNodeMsgDelay: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#delay-node-deprecated`, - ruleNodeMsgGenerator: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#generator-node`, - ruleNodeGpsGeofencingEvents: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#gps-geofencing-events-node`, - ruleNodeLog: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#log-node`, - ruleNodeRpcCallReply: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#rpc-call-reply-node`, - ruleNodeRpcCallRequest: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#rpc-call-request-node`, - ruleNodeSaveAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#save-attributes-node`, - ruleNodeDeleteAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#delete-attributes-node`, - ruleNodeSaveTimeseries: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#save-timeseries-node`, - ruleNodeSaveToCustomTable: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#save-to-custom-table-node`, - ruleNodeRuleChain: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/flow-nodes/#rule-chain-node`, - ruleNodeOutputNode: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/flow-nodes/#output-node`, - ruleNodeAiRequest: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#ai-request-node`, - ruleNodeAwsLambda: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#aws-lambda-node`, - ruleNodeAwsSns: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#aws-sns-node`, - ruleNodeAwsSqs: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#aws-sqs-node`, - ruleNodeKafka: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#kafka-node`, - ruleNodeMqtt: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#mqtt-node`, - ruleNodeAzureIotHub: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#azure-iot-hub-node`, - ruleNodeRabbitMq: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#rabbitmq-node`, - ruleNodeRestApiCall: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#rest-api-call-node`, - ruleNodeSendEmail: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#send-email-node`, - ruleNodeSendSms: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#send-sms-node`, - ruleNodeMath: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#math-function-node`, - ruleNodeCalculateDelta: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#calculate-delta`, - ruleNodeRestCallReply: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#rest-call-reply-node`, - ruleNodePushToCloud: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#push-to-cloud`, - ruleNodePushToEdge: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#push-to-edge`, - ruleNodeDeviceProfile: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#device-profile-node`, - ruleNodeAcknowledge: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/flow-nodes/#acknowledge-node`, - ruleNodeCheckpoint: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/flow-nodes/#checkpoint-node`, - ruleNodeSendNotification: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#send-notification-node`, - ruleNodeSendSlack: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#send-to-slack-node`, + ruleNodeCheckRelation: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/check-relation-presence/`, + ruleNodeCheckExistenceFields: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/check-fields-presence/`, + ruleNodeGpsGeofencingFilter: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/gps-geofencing-filter/`, + ruleNodeJsFilter: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/script/`, + ruleNodeJsSwitch: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/switch/`, + ruleNodeAssetProfileSwitch: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/asset-profile-switch/`, + ruleNodeDeviceProfileSwitch: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/device-profile-switch/`, + ruleNodeCheckAlarmStatus: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/alarm-status-filter/`, + ruleNodeMessageTypeFilter: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/message-type-filter/`, + ruleNodeMessageTypeSwitch: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/message-type-switch/`, + ruleNodeOriginatorTypeFilter: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/entity-type-filter/`, + ruleNodeOriginatorTypeSwitch: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/entity-type-switch/`, + ruleNodeOriginatorAttributes: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/originator-attributes/`, + ruleNodeOriginatorFields: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/originator-fields/`, + ruleNodeOriginatorTelemetry: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/originator-telemetry/`, + ruleNodeCustomerAttributes: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/customer-attributes/`, + ruleNodeCustomerDetails: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/customer-details/`, + ruleNodeFetchDeviceCredentials: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/fetch-device-credentials/`, + ruleNodeDeviceAttributes: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/related-device-attributes/`, + ruleNodeRelatedAttributes: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/related-entity-data/`, + ruleNodeTenantAttributes: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/tenant-attributes/`, + ruleNodeTenantDetails: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/tenant-details/`, + ruleNodeChangeOriginator: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/change-originator/`, + ruleNodeCopyKeyValuePairs: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/copy-key-value-pairs/`, + ruleNodeDeduplication: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/deduplication/`, + ruleNodeDeleteKeyValuePairs: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/delete-key-value-pairs/`, + ruleNodeJsonPath: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/json-path/`, + ruleNodeRenameKeys: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/rename-keys/`, + ruleNodeTransformMsg: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/script/`, + ruleNodeSplitArrayMsg: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/split-array-msg/`, + ruleNodeMsgToEmail: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/to-email/`, + ruleNodeAssignToCustomer: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/assign-to-customer/`, + ruleNodeUnassignFromCustomer: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/unassign-from-customer/`, + ruleNodeCalculatedFields: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/calculated-fields/`, + ruleNodeClearAlarm: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/clear-alarm/`, + ruleNodeCreateAlarm: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/create-alarm/`, + ruleNodeCopyToView: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/copy-to-view/`, + ruleNodeCreateRelation: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/create-relation/`, + ruleNodeDeleteRelation: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/delete-relation/`, + ruleNodeDeviceState: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/device-state/`, + ruleNodeMessageCount: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/message-count/`, + ruleNodeMsgDelay: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/delay/`, + ruleNodeMsgGenerator: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/generator/`, + ruleNodeGpsGeofencingEvents: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/gps-geofencing-events/`, + ruleNodeLog: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/log/`, + ruleNodeRpcCallReply: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/rpc-call-reply/`, + ruleNodeRpcCallRequest: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/rpc-call-request/`, + ruleNodeSaveAttributes: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/save-attributes/`, + ruleNodeDeleteAttributes: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/delete-attributes/`, + ruleNodeSaveTimeseries: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/save-timeseries/`, + ruleNodeSaveToCustomTable: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/save-to-custom-table/`, + ruleNodeRuleChain: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/flow/rule-chain/`, + ruleNodeOutputNode: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/flow/output/`, + ruleNodeAiRequest: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/ai-request/`, + ruleNodeAwsLambda: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/aws-lambda/`, + ruleNodeAwsSns: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/aws-sns/`, + ruleNodeAwsSqs: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/aws-sqs/`, + ruleNodeKafka: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/kafka/`, + ruleNodeMqtt: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/mqtt/`, + ruleNodeAzureIotHub: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/azure-iot-hub/`, + ruleNodeGcpPubSub: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/gcp-pubsub/`, + ruleNodeRabbitMq: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/rabbitmq/`, + ruleNodeRestApiCall: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/rest-api-call/`, + ruleNodeSendEmail: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/send-email/`, + ruleNodeSendSms: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/send-sms/`, + ruleNodeMath: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/math-function/`, + ruleNodeCalculateDelta: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/calculate-delta/`, + ruleNodeRestCallReply: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/rest-call-reply/`, + ruleNodePushToCloud: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/push-to-cloud/`, + ruleNodePushToEdge: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/push-to-edge/`, + ruleNodeDeviceProfile: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/device-profile/`, + ruleNodeAcknowledge: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/flow/acknowledge/`, + ruleNodeCheckpoint: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/flow/checkpoint/`, + ruleNodeSendNotification: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/send-notification/`, + ruleNodeSendSlack: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/send-to-slack/`, tenants: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/tenants`, tenantProfiles: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/tenant-profiles`, customers: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/customers`, diff --git a/ui-ngx/src/app/shared/models/device.models.ts b/ui-ngx/src/app/shared/models/device.models.ts index 8298d3a1fe..c2b95037a4 100644 --- a/ui-ngx/src/app/shared/models/device.models.ts +++ b/ui-ngx/src/app/shared/models/device.models.ts @@ -22,7 +22,7 @@ import { DeviceCredentialsId } from '@shared/models/id/device-credentials-id'; import { EntitySearchQuery } from '@shared/models/relation.models'; import { DeviceProfileId } from '@shared/models/id/device-profile-id'; import { RuleChainId } from '@shared/models/id/rule-chain-id'; -import { EntityInfoData, HasTenantId, HasVersion } from '@shared/models/entity.models'; +import { EntityInfoData, HasTenantId, HasVersion, SaveEntityParams } from '@shared/models/entity.models'; import { FilterPredicateValue, KeyFilter } from '@shared/models/query/query.models'; import { TimeUnit } from '@shared/models/time/time.models'; import _moment from 'moment'; @@ -739,6 +739,10 @@ export interface DeviceInfoFilter { active?: boolean; } +export interface SaveDeviceParams extends SaveEntityParams { + accessToken?: string; +} + export class DeviceInfoQuery { pageLink: PageLink; diff --git a/ui-ngx/src/app/shared/models/entity.models.ts b/ui-ngx/src/app/shared/models/entity.models.ts index 5aa526b583..a0e6b89c32 100644 --- a/ui-ngx/src/app/shared/models/entity.models.ts +++ b/ui-ngx/src/app/shared/models/entity.models.ts @@ -163,6 +163,11 @@ export const entityFields: {[fieldName: string]: EntityField} = { name: 'entity-field.label', value: 'label' }, + displayName: { + keyName: 'displayName', + name: 'entity-field.name', + value: 'name' + }, queueName: { keyName: 'queueName', name: 'entity-field.queue-name', @@ -209,3 +214,19 @@ export interface EntityTestScriptResult { } export type VersionedEntity = EntityInfoData & HasVersion | RuleChainMetaData; + +export enum NameConflictPolicy { + FAIL = 'FAIL', + UNIQUIFY = 'UNIQUIFY', +} + +export enum UniquifyStrategy { + RANDOM = 'RANDOM', + INCREMENTAL = 'INCREMENTAL' +} + +export interface SaveEntityParams { + nameConflictPolicy?: NameConflictPolicy; + uniquifyStrategy?: UniquifyStrategy; + uniquifySeparator?: string; +} diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index c1e692e04f..e19f1c82a0 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -24,7 +24,8 @@ export enum ResourceType { LWM2M_MODEL = 'LWM2M_MODEL', PKCS_12 = 'PKCS_12', JKS = 'JKS', - JS_MODULE = 'JS_MODULE' + JS_MODULE = 'JS_MODULE', + GENERAL = 'GENERAL', } export enum ResourceSubType { @@ -57,7 +58,8 @@ export const ResourceTypeTranslationMap = new Map( [ResourceType.LWM2M_MODEL, 'resource.type.lwm2m-model'], [ResourceType.PKCS_12, 'resource.type.pkcs-12'], [ResourceType.JKS, 'resource.type.jks'], - [ResourceType.JS_MODULE, 'resource.type.js-module'] + [ResourceType.JS_MODULE, 'resource.type.js-module'], + [ResourceType.GENERAL, 'resource.type.general'], ] ); @@ -76,8 +78,8 @@ export interface TbResourceInfo extends Omit, 'name' | title?: string; resourceType: ResourceType; resourceSubType?: ResourceSubType; - fileName: string; - public: boolean; + fileName?: string; + public?: boolean; publicResourceKey?: string; readonly link?: string; readonly publicLink?: string; @@ -87,7 +89,7 @@ export interface TbResourceInfo extends Omit, 'name' | export type ResourceInfo = TbResourceInfo; export interface Resource extends ResourceInfo { - data: string; + data?: string; name?: string; } diff --git a/ui-ngx/src/app/shared/models/rule-node.models.ts b/ui-ngx/src/app/shared/models/rule-node.models.ts index 8225fbf18f..786fd72448 100644 --- a/ui-ngx/src/app/shared/models/rule-node.models.ts +++ b/ui-ngx/src/app/shared/models/rule-node.models.ts @@ -476,7 +476,13 @@ const ruleNodeClazzHelpLinkMap = { 'org.thingsboard.rule.engine.metadata.TbGetTenantDetailsNode': 'ruleNodeTenantDetails', 'org.thingsboard.rule.engine.metadata.CalculateDeltaNode': 'ruleNodeCalculateDelta', 'org.thingsboard.rule.engine.transform.TbChangeOriginatorNode': 'ruleNodeChangeOriginator', + 'org.thingsboard.rule.engine.transform.TbCopyKeysNode': 'ruleNodeCopyKeyValuePairs', + 'org.thingsboard.rule.engine.deduplication.TbMsgDeduplicationNode': 'ruleNodeDeduplication', + 'org.thingsboard.rule.engine.transform.TbDeleteKeysNode': 'ruleNodeDeleteKeyValuePairs', + 'org.thingsboard.rule.engine.transform.TbJsonPathNode': 'ruleNodeJsonPath', + 'org.thingsboard.rule.engine.transform.TbRenameKeysNode': 'ruleNodeRenameKeys', 'org.thingsboard.rule.engine.transform.TbTransformMsgNode': 'ruleNodeTransformMsg', + 'org.thingsboard.rule.engine.transform.TbSplitArrayMsgNode': 'ruleNodeSplitArrayMsg', 'org.thingsboard.rule.engine.mail.TbMsgToEmailNode': 'ruleNodeMsgToEmail', 'org.thingsboard.rule.engine.action.TbAssignToCustomerNode': 'ruleNodeAssignToCustomer', 'org.thingsboard.rule.engine.action.TbUnassignFromCustomerNode': 'ruleNodeUnassignFromCustomer', @@ -505,6 +511,7 @@ const ruleNodeClazzHelpLinkMap = { 'org.thingsboard.rule.engine.kafka.TbKafkaNode': 'ruleNodeKafka', 'org.thingsboard.rule.engine.mqtt.TbMqttNode': 'ruleNodeMqtt', 'org.thingsboard.rule.engine.mqtt.azure.TbAzureIotHubNode': 'ruleNodeAzureIotHub', + 'org.thingsboard.rule.engine.gcp.pubsub.TbPubSubNode': 'ruleNodeGcpPubSub', 'org.thingsboard.rule.engine.rabbitmq.TbRabbitMqNode': 'ruleNodeRabbitMq', 'org.thingsboard.rule.engine.rest.TbRestApiCallNode': 'ruleNodeRestApiCall', 'org.thingsboard.rule.engine.mail.TbSendEmailNode': 'ruleNodeSendEmail', diff --git a/ui-ngx/src/app/shared/models/tenant.model.ts b/ui-ngx/src/app/shared/models/tenant.model.ts index 059fe39ada..34023f939b 100644 --- a/ui-ngx/src/app/shared/models/tenant.model.ts +++ b/ui-ngx/src/app/shared/models/tenant.model.ts @@ -19,6 +19,11 @@ import { TenantId } from './id/tenant-id'; import { TenantProfileId } from '@shared/models/id/tenant-profile-id'; import { BaseData, ExportableEntity } from '@shared/models/base-data'; import { QueueInfo } from '@shared/models/queue.models'; +import { FormControl } from '@angular/forms'; + +export type FormControlsFrom = { + [K in keyof T]-?: FormControl; +}; export enum TenantProfileType { DEFAULT = 'DEFAULT' @@ -101,6 +106,10 @@ export interface DefaultTenantProfileConfiguration { maxCalculatedFieldsPerEntity: number; maxArgumentsPerCF: number; + maxRelationLevelPerCfArgument: number; + minAllowedDeduplicationIntervalInSecForCF: number; + maxRelatedEntitiesToReturnPerCfArgument: number; + minAllowedScheduledUpdateIntervalInSecForCF: number; maxDataPointsPerRollingArg: number; maxStateSizeInKBytes: number; maxSingleValueArgumentSizeInKBytes: number; @@ -165,6 +174,10 @@ export function createTenantProfileConfiguration(type: TenantProfileType): Tenan maxCalculatedFieldsPerEntity: 5, maxArgumentsPerCF: 10, maxDataPointsPerRollingArg: 1000, + maxRelationLevelPerCfArgument: 10, + minAllowedDeduplicationIntervalInSecForCF: 60, + maxRelatedEntitiesToReturnPerCfArgument: 100, + minAllowedScheduledUpdateIntervalInSecForCF: 0, maxStateSizeInKBytes: 32, maxSingleValueArgumentSizeInKBytes: 2, calculatedFieldDebugEventsRateLimit: '' 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 35ff3a0572..f84a883235 100644 --- a/ui-ngx/src/app/shared/models/time/time.models.ts +++ b/ui-ngx/src/app/shared/models/time/time.models.ts @@ -20,6 +20,7 @@ import moment_ from 'moment'; import * as momentTz from 'moment-timezone'; import { IntervalType } from '@shared/models/telemetry/telemetry.models'; import { FormGroup } from '@angular/forms'; +import { ToggleHeaderOption } from '@shared/components/toggle-header.component'; const moment = moment_; @@ -524,17 +525,20 @@ export const timewindowTypeChanged = (newTimewindow: Timewindow, oldTimewindow: }; export const updateFormValuesOnTimewindowTypeChange = (selectedTab: TimewindowType, - quickIntervalOnly: boolean, timewindowForm: FormGroup, + timewindowForm: FormGroup, realtimeDisableCustomInterval: boolean, historyDisableCustomInterval: boolean, - realtimeAdvancedParams?: TimewindowAdvancedParams, - historyAdvancedParams?: TimewindowAdvancedParams) => { + realtimeAdvancedParams: TimewindowAdvancedParams, + historyAdvancedParams: TimewindowAdvancedParams, + realtimeTimewindowOptions: ToggleHeaderOption[], + historyTimewindowOptions: ToggleHeaderOption[]) => { const timewindowFormValue = timewindowForm.getRawValue(); if (selectedTab === TimewindowType.REALTIME) { - if (timewindowFormValue.history.historyType !== HistoryWindowType.FIXED - && !(quickIntervalOnly && timewindowFormValue.history.historyType === HistoryWindowType.LAST_INTERVAL)) { - if (Object.keys(RealtimeWindowType).includes(HistoryWindowType[timewindowFormValue.history.historyType])) { - timewindowForm.get('realtime.realtimeType').patchValue(RealtimeWindowType[HistoryWindowType[timewindowFormValue.history.historyType]]); - } + const sameWindowTypeOptionAvailable = realtimeTimewindowOptions.some( + option => { + return option.value === RealtimeWindowType[HistoryWindowType[timewindowFormValue.history.historyType]] + }); + if (sameWindowTypeOptionAvailable) { + timewindowForm.get('realtime.realtimeType').patchValue(RealtimeWindowType[HistoryWindowType[timewindowFormValue.history.historyType]]); if (!realtimeDisableCustomInterval || !realtimeAdvancedParams?.allowedLastIntervals?.length || realtimeAdvancedParams.allowedLastIntervals.includes(timewindowFormValue.history.timewindowMs)) { timewindowForm.get('realtime.timewindowMs').patchValue(timewindowFormValue.history.timewindowMs); @@ -552,20 +556,26 @@ export const updateFormValuesOnTimewindowTypeChange = (selectedTab: TimewindowTy } } } else { - timewindowForm.get('history.historyType').patchValue(HistoryWindowType[RealtimeWindowType[timewindowFormValue.realtime.realtimeType]]); - if (!historyDisableCustomInterval || + const sameWindowTypeOptionAvailable = historyTimewindowOptions.some( + option => { + return option.value === HistoryWindowType[RealtimeWindowType[timewindowFormValue.realtime.realtimeType]] + }); + if (sameWindowTypeOptionAvailable) { + timewindowForm.get('history.historyType').patchValue(HistoryWindowType[RealtimeWindowType[timewindowFormValue.realtime.realtimeType]]); + if (!historyDisableCustomInterval || !historyAdvancedParams?.allowedLastIntervals?.length || historyAdvancedParams.allowedLastIntervals?.includes(timewindowFormValue.realtime.timewindowMs)) { - timewindowForm.get('history.timewindowMs').patchValue(timewindowFormValue.realtime.timewindowMs); - } - if (!historyAdvancedParams?.allowedQuickIntervals?.length || historyAdvancedParams.allowedQuickIntervals?.includes(timewindowFormValue.realtime.quickInterval)) { - timewindowForm.get('history.quickInterval').patchValue(timewindowFormValue.realtime.quickInterval); - } - const defaultAggInterval = historyDefaultAggInterval(timewindowForm.getRawValue(), historyAdvancedParams); - const allowedAggIntervals = historyAllowedAggIntervals(timewindowForm.getRawValue(), historyAdvancedParams); - if (defaultAggInterval || !allowedAggIntervals.length || allowedAggIntervals.includes(timewindowFormValue.realtime.interval)) { - setTimeout(() => timewindowForm.get('history.interval').patchValue( - defaultAggInterval ?? timewindowFormValue.realtime.interval - )); + timewindowForm.get('history.timewindowMs').patchValue(timewindowFormValue.realtime.timewindowMs); + } + if (!historyAdvancedParams?.allowedQuickIntervals?.length || historyAdvancedParams.allowedQuickIntervals?.includes(timewindowFormValue.realtime.quickInterval)) { + timewindowForm.get('history.quickInterval').patchValue(timewindowFormValue.realtime.quickInterval); + } + const defaultAggInterval = historyDefaultAggInterval(timewindowForm.getRawValue(), historyAdvancedParams); + const allowedAggIntervals = historyAllowedAggIntervals(timewindowForm.getRawValue(), historyAdvancedParams); + if (defaultAggInterval || !allowedAggIntervals.length || allowedAggIntervals.includes(timewindowFormValue.realtime.interval)) { + setTimeout(() => timewindowForm.get('history.interval').patchValue( + defaultAggInterval ?? timewindowFormValue.realtime.interval + )); + } } } timewindowForm.patchValue({ @@ -1125,6 +1135,7 @@ export const cloneSelectedTimewindow = (timewindow: Timewindow): Timewindow => { export const clearTimewindowConfig = (timewindow: Timewindow, quickIntervalOnly: boolean, historyOnly: boolean, hasAggregation: boolean, hasTimezone = true): Timewindow => { + const noneAggregation = hasAggregation && timewindow.aggregation?.type === AggregationType.NONE; if (timewindow.selectedTab === TimewindowType.REALTIME) { if (quickIntervalOnly || timewindow.realtime.realtimeType === RealtimeWindowType.INTERVAL) { delete timewindow.realtime.timewindowMs; @@ -1138,7 +1149,7 @@ export const clearTimewindowConfig = (timewindow: Timewindow, quickIntervalOnly: delete timewindow.history?.quickInterval; delete timewindow.history?.interval; - if (!hasAggregation) { + if (!hasAggregation || noneAggregation) { delete timewindow.realtime.interval; } } else { @@ -1162,13 +1173,15 @@ export const clearTimewindowConfig = (timewindow: Timewindow, quickIntervalOnly: delete timewindow.realtime?.quickInterval; delete timewindow.realtime?.interval; - if (!hasAggregation) { + if (!hasAggregation || noneAggregation) { delete timewindow.history.interval; } } if (!hasAggregation) { delete timewindow.aggregation; + } else if (!noneAggregation) { + delete timewindow.aggregation.limit; } if (historyOnly) { diff --git a/ui-ngx/src/app/shared/models/two-factor-auth.models.ts b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts index f2f392bd04..96a8d8186e 100644 --- a/ui-ngx/src/app/shared/models/two-factor-auth.models.ts +++ b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts @@ -14,7 +14,11 @@ /// limitations under the License. /// +import { UsersFilter } from '@shared/models/notification.models'; + export interface TwoFactorAuthSettings { + enforceTwoFa: boolean; + enforcedUsersFilter: UsersFilter; maxVerificationFailuresBeforeUserLockout: number; providers: Array; totalAllowedTimeForVerification: number; @@ -24,12 +28,18 @@ export interface TwoFactorAuthSettings { } export interface TwoFactorAuthSettingsForm extends TwoFactorAuthSettings{ + enforceTwoFa: boolean; + enforcedUsersFilter: UsersFilterWithFilterByTenant; providers: Array; verificationCodeCheckRateLimitEnable: boolean; verificationCodeCheckRateLimitNumber: number; verificationCodeCheckRateLimitTime: number; } +export interface UsersFilterWithFilterByTenant extends UsersFilter{ + filterByTenants?: boolean; +} + export type TwoFactorAuthProviderConfig = Partial; @@ -183,3 +193,61 @@ export const twoFactorAuthProvidersLoginData = new Map>( + [ + [ + TwoFactorAuthProviderType.TOTP, { + name: 'login.enable-authenticator-app', + description: 'login.enable-authenticator-app-description' + } + ], + [ + TwoFactorAuthProviderType.SMS, { + name: 'login.enable-authenticator-sms', + description: 'login.enable-authenticator-sms-description' + } + ], + [ + TwoFactorAuthProviderType.EMAIL, { + name: 'login.enable-authenticator-email', + description: 'login.enable-authenticator-email-description' + } + ], + [ + TwoFactorAuthProviderType.BACKUP_CODE, { + name: 'security.2fa.provider.backup_code', + description: 'login.backup-code-auth-description' + } + ] + ] +); + +export const twoFactorAuthProvidersSuccessCardTranslate = new Map>( + [ + [ + TwoFactorAuthProviderType.TOTP, { + name: 'login.authenticator-app-success', + description: 'login.authenticator-app-success-description' + } + ], + [ + TwoFactorAuthProviderType.SMS, { + name: 'login.authenticator-sms-success', + description: 'login.authenticator-sms-success-description' + } + ], + [ + TwoFactorAuthProviderType.EMAIL, { + name: 'login.authenticator-email-success', + description: 'login.authenticator-email-success-description' + } + ], + [ + TwoFactorAuthProviderType.BACKUP_CODE, { + name: 'login.authenticator-backup-code-success', + description: 'login.authenticator-backup-code-success-description' + } + ] + ] +); diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index 01ac1b2aaa..f612dd1503 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -659,7 +659,8 @@ export enum MapItemType { marker = 'marker', polygon = 'polygon', rectangle = 'rectangle', - circle = 'circle' + circle = 'circle', + polyline = 'polyline' } export const widgetActionTypes = Object.keys(WidgetActionType) @@ -699,6 +700,7 @@ export const mapItemTypeTranslationMap = new Map( [ MapItemType.polygon, 'widget-action.map-item.polygon' ], [ MapItemType.rectangle, 'widget-action.map-item.rectangle' ], [ MapItemType.circle, 'widget-action.map-item.circle' ], + [ MapItemType.polyline, 'widget-action.map-item.polyline' ] ] ) @@ -826,6 +828,8 @@ export interface MapItemTooltips { finishRect?: string; startCircle?: string; finishCircle?: string; + startPolyline?: string; + finishPolyline?: string; } export const mapItemTooltipsTranslation: Required = Object.freeze({ @@ -836,7 +840,9 @@ export const mapItemTooltipsTranslation: Required = Object.free startRect: 'widgets.maps.data-layer.polygon.rectangle-place-first-point-hint', finishRect: 'widgets.maps.data-layer.polygon.finish-rectangle-hint', startCircle: 'widgets.maps.data-layer.circle.place-circle-center-hint', - finishCircle: 'widgets.maps.data-layer.circle.finish-circle-hint' + finishCircle: 'widgets.maps.data-layer.circle.finish-circle-hint', + startPolyline: 'widgets.maps.data-layer.polyline.polyline-place-first-point-hint', + finishPolyline: 'widgets.maps.data-layer.polyline.finish-polyline-hint' }) export interface WidgetActionDescriptor extends WidgetAction { @@ -1145,5 +1151,4 @@ export abstract class WidgetSettingsComponent extends PageComponent implements protected onWidgetConfigSet(widgetConfig: WidgetConfigComponentData) { } - } diff --git a/ui-ngx/src/app/shared/models/widget/home-widgets/api-usage-model.definition.ts b/ui-ngx/src/app/shared/models/widget/home-widgets/api-usage-model.definition.ts new file mode 100644 index 0000000000..5c92b84cbe --- /dev/null +++ b/ui-ngx/src/app/shared/models/widget/home-widgets/api-usage-model.definition.ts @@ -0,0 +1,88 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityAliases, EntityAliasInfo, getEntityAliasId } from '@shared/models/alias.models'; +import { FilterInfo, Filters } from '@shared/models/query/query.models'; +import { Dashboard } from '@shared/models/dashboard.models'; +import { Datasource, DatasourceType, Widget } from '@shared/models/widget.models'; +import { WidgetModelDefinition } from '@shared/models/widget/widget-model.definition'; +import { + ApiUsageWidgetSettings, + getUniqueDataKeys +} from '@home/components/widget/lib/settings/cards/api-usage-settings.component.models'; + +interface AliasFilterPair { + alias?: EntityAliasInfo; + filter?: FilterInfo; +} + +interface ApiUsageDatasourcesInfo { + ds?: AliasFilterPair; +} + +export const ApiUsageModelDefinition: WidgetModelDefinition = { + testWidget(widget: Widget): boolean { + if (widget?.config?.settings) { + const settings = widget.config.settings; + if (settings.apiUsageDataKeys && Array.isArray(settings.apiUsageDataKeys)) { + return true; + } + } + return false; + }, + prepareExportInfo(dashboard: Dashboard, widget: Widget): ApiUsageDatasourcesInfo { + const settings: ApiUsageWidgetSettings = widget.config.settings as ApiUsageWidgetSettings; + const info: ApiUsageDatasourcesInfo = {}; + if (settings.dsEntityAliasId) { + info.ds = prepareExportDataSourcesInfo(dashboard, settings.dsEntityAliasId); + } + return info; + }, + updateFromExportInfo(widget: Widget, entityAliases: EntityAliases, filters: Filters, info: ApiUsageDatasourcesInfo): void { + const settings: ApiUsageWidgetSettings = widget.config.settings as ApiUsageWidgetSettings; + if (info?.ds?.alias) { + settings.dsEntityAliasId = getEntityAliasId(entityAliases, info.ds.alias); + } + }, + datasources(widget: Widget): Datasource[] { + const settings: ApiUsageWidgetSettings = widget.config.settings as ApiUsageWidgetSettings; + const datasources: Datasource[] = []; + if (settings.apiUsageDataKeys?.length && settings.dsEntityAliasId) { + datasources.push({ + type: DatasourceType.entity, + name: '', + entityAliasId: settings.dsEntityAliasId, + dataKeys: getUniqueDataKeys(settings.apiUsageDataKeys) + }); + } + return datasources; + }, + hasTimewindow(): boolean { + return false; + } +}; + +const prepareExportDataSourcesInfo = (dashboard: Dashboard, settings: string): AliasFilterPair => { + const aliasAndFilter: AliasFilterPair = {}; + const entityAlias = dashboard.configuration.entityAliases[settings]; + if (entityAlias) { + aliasAndFilter.alias = { + alias: entityAlias.alias, + filter: entityAlias.filter + }; + } + return aliasAndFilter; +} diff --git a/ui-ngx/src/app/shared/models/widget/maps/map-model.definition.ts b/ui-ngx/src/app/shared/models/widget/maps/map-model.definition.ts index 8013889017..a5dc5e3ff4 100644 --- a/ui-ngx/src/app/shared/models/widget/maps/map-model.definition.ts +++ b/ui-ngx/src/app/shared/models/widget/maps/map-model.definition.ts @@ -17,18 +17,15 @@ import { EntityAliases, EntityAliasInfo, getEntityAliasId } from '@shared/models/alias.models'; import { FilterInfo, Filters, getFilterId } from '@shared/models/query/query.models'; import { Dashboard } from '@shared/models/dashboard.models'; -import { DataKey, Datasource, datasourcesHasAggregation, DatasourceType, Widget } from '@shared/models/widget.models'; +import { Datasource, datasourcesHasAggregation, DatasourceType, Widget } from '@shared/models/widget.models'; import { additionalMapDataSourcesToDatasources, BaseMapSettings, - CirclesDataLayerSettings, MapDataLayerSettings, MapDataLayerType, MapDataSourceSettings, mapDataSourceSettingsToDatasource, - MapType, - MarkersDataLayerSettings, - PolygonsDataLayerSettings + MapType } from '@shared/models/widget/maps/map.models'; import { WidgetModelDefinition } from '@shared/models/widget/widget-model.definition'; @@ -126,7 +123,7 @@ export const MapModelDefinition: WidgetModelDefinition = { datasources.push(...getMapDataLayersDatasources(settings.circles)); } if (settings.additionalDataSources?.length) { - datasources.push(...getMapDataLayersDatasources(settings.additionalDataSources)); + datasources.push(...additionalMapDataSourcesToDatasources(settings.additionalDataSources)); } return datasources; }, @@ -137,13 +134,13 @@ export const MapModelDefinition: WidgetModelDefinition = { } else { const datasources: Datasource[] = []; if (settings.markers?.length) { - datasources.push(...getMapLatestDataLayersDatasources(settings.markers, 'markers')); + datasources.push(...getMapDataLayersDatasources(settings.markers, true, 'markers')); } if (settings.polygons?.length) { - datasources.push(...getMapLatestDataLayersDatasources(settings.polygons, 'polygons')); + datasources.push(...getMapDataLayersDatasources(settings.polygons, true, 'polygons')); } if (settings.circles?.length) { - datasources.push(...getMapLatestDataLayersDatasources(settings.circles, 'circles')); + datasources.push(...getMapDataLayersDatasources(settings.circles, true, 'circles')); } if (settings.additionalDataSources?.length) { datasources.push(...additionalMapDataSourcesToDatasources(settings.additionalDataSources)); @@ -237,52 +234,21 @@ const prepareAliasAndFilterPair = (dashboard: Dashboard, settings: MapDataSource } } -const getMapDataLayersDatasources = (settings: MapDataLayerSettings[] | MapDataSourceSettings[]): Datasource[] => { +const getMapDataLayersDatasources = (settings: MapDataLayerSettings[], + includeDataKeys = false, dataLayerType?: MapDataLayerType): Datasource[] => { const datasources: Datasource[] = []; settings.forEach((dsSettings) => { - datasources.push(mapDataSourceSettingsToDatasource(dsSettings)); - if ((dsSettings as MapDataLayerSettings).additionalDataSources?.length) { - (dsSettings as MapDataLayerSettings).additionalDataSources.forEach((ds) => { - datasources.push(mapDataSourceSettingsToDatasource(ds)); - }); - } - }); - return datasources; -}; - -const getMapLatestDataLayersDatasources = (settings: MapDataLayerSettings[], - dataLayerType: MapDataLayerType): Datasource[] => { - const datasources: Datasource[] = []; - settings.forEach((dsSettings) => { - const dataKeys: DataKey[] = getMapLatestDataLayerDatasourceDataKeys(dsSettings, dataLayerType); - const datasource: Datasource = mapDataSourceSettingsToDatasource(dsSettings); - datasource.dataKeys.push(...dataKeys); + const datasource: Datasource = mapDataSourceSettingsToDatasource(dsSettings, null, includeDataKeys, dataLayerType); datasources.push(datasource); - if ((dsSettings).additionalDataSources?.length) { - (dsSettings).additionalDataSources.forEach((ds) => { + if (dsSettings.additionalDataSources?.length) { + dsSettings.additionalDataSources.forEach((ds) => { const additionalDatasource: Datasource = mapDataSourceSettingsToDatasource(ds); - additionalDatasource.dataKeys.push(...dataKeys); + if (includeDataKeys) { + additionalDatasource.dataKeys.push(...datasource.dataKeys); + } datasources.push(additionalDatasource); }); } }); return datasources; }; - -const getMapLatestDataLayerDatasourceDataKeys = (settings: MapDataLayerSettings, - dataLayerType: MapDataLayerType): DataKey[] => { - const dataKeys = settings.additionalDataKeys || []; - switch (dataLayerType) { - case 'markers': - const markersSettings = settings as MarkersDataLayerSettings; - dataKeys.push(markersSettings.xKey, markersSettings.yKey); - break; - case 'polygons': - dataKeys.push((settings as PolygonsDataLayerSettings).polygonKey); - break; - case 'circles': - dataKeys.push((settings as CirclesDataLayerSettings).circleKey); - break; - } - return dataKeys; -}; diff --git a/ui-ngx/src/app/shared/models/widget/maps/map.models.ts b/ui-ngx/src/app/shared/models/widget/maps/map.models.ts index 0468fe1fab..d3be99541f 100644 --- a/ui-ngx/src/app/shared/models/widget/maps/map.models.ts +++ b/ui-ngx/src/app/shared/models/widget/maps/map.models.ts @@ -24,6 +24,7 @@ import { } from '@shared/models/widget.models'; import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { + deepClone, guid, hashCode, isDefinedAndNotNull, @@ -61,19 +62,47 @@ export interface TbMapDatasource extends Datasource { mapDataIds: string[]; } -export const mapDataSourceSettingsToDatasource = (settings: MapDataSourceSettings, id = guid()): TbMapDatasource => { +export const mapDataSourceSettingsToDatasource = (settings: MapDataSourceSettings | MapDataLayerSettings, + id = guid(), + includeDataKeys = false, dataLayerType?: MapDataLayerType): TbMapDatasource => { + const dataKeys = includeDataKeys ? mapDataLayerDatasourceDataKeys((settings as MapDataLayerSettings), dataLayerType) : []; return { type: settings.dsType, name: settings.dsLabel, deviceId: settings.dsDeviceId, entityAliasId: settings.dsEntityAliasId, filterId: settings.dsFilterId, - dataKeys: [], + dataKeys: dataKeys, latestDataKeys: [], mapDataIds: [id] }; }; +const mapDataLayerDatasourceDataKeys = (settings: MapDataLayerSettings, + dataLayerType: MapDataLayerType): DataKey[] => { + const dataKeys = settings.additionalDataKeys?.length ? deepClone(settings.additionalDataKeys) : []; + switch (dataLayerType) { + case 'trips': + const tripsSettings = settings as TripsDataLayerSettings; + dataKeys.push(tripsSettings.xKey, tripsSettings.yKey); + break; + case 'markers': + const markersSettings = settings as MarkersDataLayerSettings; + dataKeys.push(markersSettings.xKey, markersSettings.yKey); + break; + case 'polygons': + dataKeys.push((settings as PolygonsDataLayerSettings).polygonKey); + break; + case 'circles': + dataKeys.push((settings as CirclesDataLayerSettings).circleKey); + break; + case 'polylines': + dataKeys.push((settings as PolylinesDataLayerSettings).polylineKey); + break; + } + return dataKeys; +}; + export enum DataLayerPatternType { pattern = 'pattern', @@ -133,6 +162,7 @@ export interface DataLayerEditSettings { snappable: boolean; } + export interface MapDataLayerSettings extends MapDataSourceSettings { additionalDataSources?: MapDataSourceSettings[]; additionalDataKeys?: DataKey[]; @@ -140,7 +170,7 @@ export interface MapDataLayerSettings extends MapDataSourceSettings { tooltip: DataLayerTooltipSettings; click: WidgetAction; groups?: string[]; - edit: DataLayerEditSettings; + edit: DataLayerEditSettings; } export const defaultBaseDataLayerSettings = (mapType: MapType): Partial => ({ @@ -156,7 +186,7 @@ export const defaultBaseDataLayerSettings = (mapType: MapType): Partial${entityName}

    Latitude: ${latitude:7}
    Longitude: ${longitude:7}
    Temperature: ${temperature} °C
    See tooltip settings for details' - : '${entityName}

    X Pos: ${xPos:2}
    Y Pos: ${yPos:2}
    Temperature: ${temperature} °C
    See tooltip settings for details', + : '${entityName}

    X Pos: ${xPos:2}
    Y Pos: ${yPos:2}
    Temperature: ${temperature} °C
    See tooltip settings for details', offsetX: 0, offsetY: -1 }, @@ -170,9 +200,9 @@ export const defaultBaseDataLayerSettings = (mapType: MapType): Partial { if (!dataLayer.dsType || ![DatasourceType.function, DatasourceType.device, DatasourceType.entity].includes(dataLayer.dsType)) { @@ -196,7 +226,7 @@ export const mapDataLayerValid = (dataLayer: MapDataLayerSettings, type: MapData case 'markers': const markersDataLayer = dataLayer as MarkersDataLayerSettings; if (!markersDataLayer.xKey?.type || !markersDataLayer.xKey?.name || - !markersDataLayer.yKey?.type || !markersDataLayer.xKey?.name) { + !markersDataLayer.yKey?.type || !markersDataLayer.xKey?.name) { return false; } break; @@ -206,6 +236,12 @@ export const mapDataLayerValid = (dataLayer: MapDataLayerSettings, type: MapData return false; } break; + case 'polylines': + const polylinesDataLayer = dataLayer as PolylinesDataLayerSettings; + if (!polylinesDataLayer.polylineKey?.type || !polylinesDataLayer.polylineKey?.name) { + return false; + } + break; case 'circles': const circlesDataLayer = dataLayer as CirclesDataLayerSettings; if (!circlesDataLayer.circleKey?.type || !circlesDataLayer.circleKey?.name) { @@ -274,6 +310,7 @@ export interface MarkerIconSettings extends BaseMarkerShapeSettings { iconContainer?: MarkerIconContainer; icon: string; } + export interface MarkerClusteringSettings { enable: boolean; zoomOnClick: boolean; @@ -574,8 +611,11 @@ export const defaultBasePolygonsDataLayerSettings = (mapType: MapType): Partial< color: '#3388ff', }, strokeWeight: 3 -} as Partial, defaultBaseDataLayerSettings(mapType), - {label: {show: false}, tooltip: {show: false, pattern: '${entityName}

    TimeStamp: ${ts:7}'}} as Partial) + } as Partial, defaultBaseDataLayerSettings(mapType), + { + label: {show: false}, + tooltip: {show: false, pattern: '${entityName}

    TimeStamp: ${ts:7}'} + } as Partial) export interface CirclesDataLayerSettings extends ShapeDataLayerSettings { circleKey: DataKey; @@ -625,8 +665,45 @@ export const defaultBaseCirclesDataLayerSettings = (mapType: MapType): Partial, defaultBaseDataLayerSettings(mapType), - {label: {show: false}, tooltip: {show: false, pattern: '${entityName}

    TimeStamp: ${ts:7}'}} as Partial) + } as Partial, defaultBaseDataLayerSettings(mapType), + { + label: {show: false}, + tooltip: {show: false, pattern: '${entityName}

    TimeStamp: ${ts:7}'} + } as Partial) + +export interface PolylinesDataLayerSettings extends ShapeDataLayerSettings { + polylineKey: DataKey; +} + +export const defaultPolylinesDataLayerSettings = (mapType: MapType, functionsOnly = false): PolylinesDataLayerSettings => mergeDeep({ + dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, + dsLabel: functionsOnly ? 'First polyline' : '', + polylineKey: { + name: functionsOnly ? 'f(x)' : 'perimeter', + label: 'perimeter', + type: functionsOnly ? DataKeyType.function : DataKeyType.attribute, + settings: {}, + color: materialColors[0].value + } +} as PolylinesDataLayerSettings, defaultBasePolylinesDataLayerSettings(mapType) as PolylinesDataLayerSettings); + +export const defaultBasePolylinesDataLayerSettings = (mapType: MapType): Partial => mergeDeep({ + fillType: ShapeFillType.color, + fillColor: { + type: DataLayerColorType.constant, + color: 'rgba(51,136,255,0.2)', + }, + strokeColor: { + type: DataLayerColorType.constant, + color: '#3388ff', + }, + strokeWeight: 3 + } as Partial, defaultBaseDataLayerSettings(mapType), + { + label: {show: false}, + tooltip: {show: false, pattern: '${entityName}

    TimeStamp: ${ts:7}'} + } as Partial) + export const defaultMapDataLayerSettings = (mapType: MapType, dataLayerType: MapDataLayerType, functionsOnly = false): MapDataLayerSettings => { switch (dataLayerType) { @@ -638,6 +715,8 @@ export const defaultMapDataLayerSettings = (mapType: MapType, dataLayerType: Map return defaultPolygonsDataLayerSettings(mapType, functionsOnly); case 'circles': return defaultCirclesDataLayerSettings(mapType, functionsOnly); + case 'polylines': + return defaultPolylinesDataLayerSettings(mapType, functionsOnly); } }; @@ -651,6 +730,8 @@ export const defaultBaseMapDataLayerSettings = ( return defaultBasePolygonsDataLayerSettings(mapType) as T; case 'circles': return defaultBaseCirclesDataLayerSettings(mapType) as T; + case 'polylines': + return defaultBasePolylinesDataLayerSettings(mapType) as T; } } @@ -658,10 +739,11 @@ export interface AdditionalMapDataSourceSettings extends MapDataSourceSettings { dataKeys: DataKey[]; } -export const additionalMapDataSourcesToDatasources = (additionalMapDataSources: AdditionalMapDataSourceSettings[]): TbMapDatasource[] => { +export const additionalMapDataSourcesToDatasources = (additionalMapDataSources: AdditionalMapDataSourceSettings[], + includeDataKeys = true): TbMapDatasource[] => { return additionalMapDataSources.map(addDs => { const res = mapDataSourceSettingsToDatasource(addDs); - res.dataKeys = addDs.dataKeys; + res.dataKeys = includeDataKeys ? addDs.dataKeys : []; return res; }); }; @@ -700,13 +782,13 @@ export const additionalMapDataSourceValid = (dataSource: AdditionalMapDataSource }; export const additionalMapDataSourceValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { - const dataSource: AdditionalMapDataSourceSettings = control.value; - if (!additionalMapDataSourceValid(dataSource)) { - return { - dataSource: true - }; - } - return null; + const dataSource: AdditionalMapDataSourceSettings = control.value; + if (!additionalMapDataSourceValid(dataSource)) { + return { + dataSource: true + }; + } + return null; }; export const defaultAdditionalMapDataSourceSettings = (functionsOnly = false): AdditionalMapDataSourceSettings => { @@ -788,6 +870,7 @@ export interface BaseMapSettings { markers: MarkersDataLayerSettings[]; polygons: PolygonsDataLayerSettings[]; circles: CirclesDataLayerSettings[]; + polylines: PolylinesDataLayerSettings[]; additionalDataSources: AdditionalMapDataSourceSettings[]; controlsPosition: MapControlsPosition; zoomActions: MapZoomAction[]; @@ -812,6 +895,7 @@ export const defaultBaseMapSettings: BaseMapSettings = { markers: [], polygons: [], circles: [], + polylines: [], additionalDataSources: [], controlsPosition: MapControlsPosition.topleft, zoomActions: [MapZoomAction.scroll, MapZoomAction.doubleClick, MapZoomAction.controlButtons], @@ -1218,6 +1302,12 @@ export type TbPolyData = L.LatLngTuple[] | L.LatLngTuple[][] | L.LatLngTuple[][] export type TbPolygonCoordinate = L.LatLng | L.LatLng[] | L.LatLng[][]; export type TbPolygonCoordinates = TbPolygonCoordinate[]; +export type TbPolylineRawCoordinate = L.LatLngTuple | L.LatLngTuple[] | L.LatLngTuple[][]; +export type TbPolylineRawCoordinates = TbPolylineRawCoordinate[]; +export type TbPolylineData = L.LatLngTuple[] | L.LatLngTuple[][] | L.LatLngTuple[][][]; +export type TbPolylineCoordinate = L.LatLng | L.LatLng[] | L.LatLng[][]; +export type TbPolylineCoordinates = TbPolylineCoordinate[]; + export interface TbCircleData { latitude: number; longitude: number; diff --git a/ui-ngx/src/app/shared/models/widget/widget-model.definition.ts b/ui-ngx/src/app/shared/models/widget/widget-model.definition.ts index 21dc801b3f..ccec658766 100644 --- a/ui-ngx/src/app/shared/models/widget/widget-model.definition.ts +++ b/ui-ngx/src/app/shared/models/widget/widget-model.definition.ts @@ -19,6 +19,7 @@ import { Dashboard } from '@shared/models/dashboard.models'; import { EntityAliases } from '@shared/models/alias.models'; import { Filters } from '@shared/models/query/query.models'; import { MapModelDefinition } from '@shared/models/widget/maps/map-model.definition'; +import { ApiUsageModelDefinition } from '@shared/models/widget/home-widgets/api-usage-model.definition'; export interface WidgetModelDefinition { testWidget(widget: Widget): boolean; @@ -29,7 +30,8 @@ export interface WidgetModelDefinition { } const widgetModelRegistry: WidgetModelDefinition[] = [ - MapModelDefinition + MapModelDefinition, + ApiUsageModelDefinition ]; export const findWidgetModelDefinition = (widget: Widget): WidgetModelDefinition => { diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 3e414577ec..b94e72d76f 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -237,6 +237,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) @NgModule({ providers: [ DatePipe, + SelectableColumnsPipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, HighlightPipe, diff --git a/ui-ngx/src/assets/dashboard/api_usage.json b/ui-ngx/src/assets/dashboard/api_usage.json index 3fc49e171f..994b431ade 100644 --- a/ui-ngx/src/assets/dashboard/api_usage.json +++ b/ui-ngx/src/assets/dashboard/api_usage.json @@ -98,7 +98,8 @@ "settings": { "showTimestamp": true, "displayPagination": true, - "defaultPageSize": 10 + "defaultPageSize": 10, + "tabSortKey": "NAME_ASC" }, "title": "{i18n:api-usage.exceptions}", "dropShadow": true, @@ -1216,35 +1217,16 @@ } ], "timewindow": { - "hideAggregation": false, - "hideAggInterval": false, - "hideTimezone": false, "selectedTab": 0, "realtime": { "realtimeType": 0, "interval": 3600000, - "timewindowMs": 86400000, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false - }, - "history": { - "historyType": 0, - "interval": 1000, - "timewindowMs": 60000, - "fixedTimewindow": null, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideFixedInterval": false, - "hideQuickInterval": false + "timewindowMs": 86400000 }, "aggregation": { - "type": "SUM", + "type": "NONE", "limit": 50000 - }, - "timezone": null + } }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -1589,35 +1571,16 @@ } ], "timewindow": { - "hideAggregation": false, - "hideAggInterval": false, - "hideTimezone": false, "selectedTab": 0, "realtime": { "realtimeType": 0, "interval": 3600000, - "timewindowMs": 86400000, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false - }, - "history": { - "historyType": 0, - "interval": 1000, - "timewindowMs": 60000, - "fixedTimewindow": null, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideFixedInterval": false, - "hideQuickInterval": false + "timewindowMs": 86400000 }, "aggregation": { - "type": "SUM", + "type": "NONE", "limit": 50000 - }, - "timezone": null + } }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -1998,35 +1961,16 @@ } ], "timewindow": { - "hideAggregation": false, - "hideAggInterval": false, - "hideTimezone": false, "selectedTab": 0, "realtime": { "realtimeType": 0, "interval": 3600000, - "timewindowMs": 86400000, - "quickInterval": "CURRENT_YEAR", - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false - }, - "history": { - "historyType": 0, - "interval": 1000, - "timewindowMs": 60000, - "fixedTimewindow": null, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideFixedInterval": false, - "hideQuickInterval": false + "timewindowMs": 86400000 }, "aggregation": { - "type": "SUM", + "type": "NONE", "limit": 50000 - }, - "timezone": null + } }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -2418,35 +2362,16 @@ } ], "timewindow": { - "hideAggregation": false, - "hideAggInterval": false, - "hideTimezone": false, "selectedTab": 0, "realtime": { "realtimeType": 0, "interval": 3600000, - "timewindowMs": 86400000, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false - }, - "history": { - "historyType": 0, - "interval": 1000, - "timewindowMs": 60000, - "fixedTimewindow": null, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideFixedInterval": false, - "hideQuickInterval": false + "timewindowMs": 86400000 }, "aggregation": { - "type": "SUM", + "type": "NONE", "limit": 50000 - }, - "timezone": null + } }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -2680,7 +2605,7 @@ "tooltipHideZeroValues": null, "padding": "12px" }, - "title": "{i18n:api-usage.telemetry-persistence-hourly-activity}", + "title": "{i18n:api-usage.data-points-storage-days-hourly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -2733,7 +2658,7 @@ "col": 0, "id": "5d0f2f57-499d-1324-8e1b-cfbc0b3149d2" }, - "51608a74-f213-d8c9-8df8-b42238ef93a6": { + "fb155957-1af4-233e-e2fb-09e648e75d6e": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -2794,26 +2719,16 @@ "hideAggregation": false, "hideAggInterval": false, "hideTimezone": false, - "selectedTab": 0, - "realtime": { - "realtimeType": 0, - "interval": 3600000, - "timewindowMs": 86400000, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false - }, + "selectedTab": 1, "history": { "historyType": 0, - "interval": 86400000, "timewindowMs": 2592000000, - "fixedTimewindow": null, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideFixedInterval": false, - "hideQuickInterval": false + "interval": 86400000, + "fixedTimewindow": { + "startTimeMs": 1709729389667, + "endTimeMs": 1709815789667 + }, + "quickInterval": "CURRENT_DAY" }, "aggregation": { "type": "SUM", @@ -3053,7 +2968,7 @@ "tooltipHideZeroValues": null, "padding": "12px" }, - "title": "{i18n:api-usage.transport-msg-hourly-activity}", + "title": "{i18n:api-usage.transport-msg-daily-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -3102,9 +3017,9 @@ }, "row": 0, "col": 0, - "id": "51608a74-f213-d8c9-8df8-b42238ef93a6" + "id": "fb155957-1af4-233e-e2fb-09e648e75d6e" }, - "fb155957-1af4-233e-e2fb-09e648e75d6e": { + "4817e33b-87be-5be3-eaca-ca68a2eb4e0c": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -3153,12 +3068,7 @@ "usePostProcessing": null, "postFuncBody": null } - ], - "alarmFilterConfig": { - "statusList": [ - "ACTIVE" - ] - } + ] } ], "timewindow": { @@ -3166,18 +3076,28 @@ "hideAggInterval": false, "hideTimezone": false, "selectedTab": 1, + "realtime": { + "realtimeType": 0, + "interval": 1000, + "timewindowMs": 60000, + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideQuickInterval": false + }, "history": { "historyType": 0, - "timewindowMs": 2592000000, - "interval": 86400000, - "fixedTimewindow": { - "startTimeMs": 1709729389667, - "endTimeMs": 1709815789667 - }, - "quickInterval": "CURRENT_DAY" + "interval": 2592000000, + "timewindowMs": 31536000000, + "fixedTimewindow": null, + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideFixedInterval": false, + "hideQuickInterval": false }, "aggregation": { - "type": "SUM", + "type": "NONE", "limit": 25000 }, "timezone": null @@ -3414,7 +3334,7 @@ "tooltipHideZeroValues": null, "padding": "12px" }, - "title": "{i18n:api-usage.transport-msg-daily-activity}", + "title": "{i18n:api-usage.transport-msg-monthly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -3463,9 +3383,9 @@ }, "row": 0, "col": 0, - "id": "fb155957-1af4-233e-e2fb-09e648e75d6e" + "id": "4817e33b-87be-5be3-eaca-ca68a2eb4e0c" }, - "4817e33b-87be-5be3-eaca-ca68a2eb4e0c": { + "79056202-c92b-1dae-ce49-318ec52e2d3b": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -3479,10 +3399,10 @@ "filterId": null, "dataKeys": [ { - "name": "transportMsgCountHourly", + "name": "transportDataPointsCountHourly", "type": "timeseries", - "label": "{i18n:api-usage.transport-messages}", - "color": "#2196f3", + "label": "{i18n:api-usage.transport-data-points}", + "color": "#4CAF50", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -3505,16 +3425,23 @@ "comparisonSettings": { "showValuesForComparison": true }, - "type": "bar" + "type": "bar", + "yAxisId": "default" }, "_hash": 0.0661644137210089, "units": null, "decimals": null, "funcBody": null, "usePostProcessing": null, - "postFuncBody": null + "postFuncBody": null, + "aggregationType": null } - ] + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } } ], "timewindow": { @@ -3522,28 +3449,18 @@ "hideAggInterval": false, "hideTimezone": false, "selectedTab": 1, - "realtime": { - "realtimeType": 0, - "interval": 1000, - "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false - }, "history": { "historyType": 0, - "interval": 2592000000, - "timewindowMs": 31536000000, - "fixedTimewindow": null, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideFixedInterval": false, - "hideQuickInterval": false + "timewindowMs": 2592000000, + "interval": 86400000, + "fixedTimewindow": { + "startTimeMs": 1709729389667, + "endTimeMs": 1709815789667 + }, + "quickInterval": "CURRENT_DAY" }, "aggregation": { - "type": "NONE", + "type": "SUM", "limit": 25000 }, "timezone": null @@ -3780,7 +3697,7 @@ "tooltipHideZeroValues": null, "padding": "12px" }, - "title": "{i18n:api-usage.transport-msg-monthly-activity}", + "title": "{i18n:api-usage.transport-data-points-daily-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -3829,9 +3746,9 @@ }, "row": 0, "col": 0, - "id": "4817e33b-87be-5be3-eaca-ca68a2eb4e0c" + "id": "79056202-c92b-1dae-ce49-318ec52e2d3b" }, - "9e00cc90-520d-2108-1d2f-bba68ed5cbf1": { + "966ffee7-ba0d-8e54-f903-e8d015ca8cd2": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -3850,1599 +3767,9 @@ "label": "{i18n:api-usage.transport-data-points}", "color": "#4CAF50", "settings": { - "excludeFromStacking": false, - "hideDataByDefault": false, - "disableDataHiding": false, - "removeFromLegend": false, - "showLines": false, - "fillLines": false, - "showPoints": false, - "showPointShape": "circle", - "pointShapeFormatter": "", - "showPointsLineWidth": 5, - "showPointsRadius": 3, - "showSeparateAxis": false, - "axisPosition": "left", - "thresholds": [ - { - "thresholdValueSource": "predefinedValue" - } - ], - "comparisonSettings": { - "showValuesForComparison": true - }, - "type": "bar", - "yAxisId": "default" - }, - "_hash": 0.0661644137210089, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": null, - "postFuncBody": null, - "aggregationType": null - } - ], - "alarmFilterConfig": { - "statusList": [ - "ACTIVE" - ] - } - } - ], - "timewindow": { - "hideAggregation": false, - "hideAggInterval": false, - "hideTimezone": false, - "selectedTab": 0, - "realtime": { - "realtimeType": 0, - "interval": 3600000, - "timewindowMs": 86400000, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false - }, - "history": { - "historyType": 0, - "interval": 86400000, - "timewindowMs": 2592000000, - "fixedTimewindow": null, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideFixedInterval": false, - "hideQuickInterval": false - }, - "aggregation": { - "type": "SUM", - "limit": 25000 - }, - "timezone": null - }, - "showTitle": true, - "backgroundColor": "#FFFFFF", - "color": "rgba(0, 0, 0, 0.87)", - "padding": "0px", - "settings": { - "yAxes": { - "default": { - "units": null, - "decimals": 0, - "show": true, - "label": "", - "labelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "600", - "lineHeight": "1" - }, - "labelColor": "rgba(0, 0, 0, 0.54)", - "position": "left", - "showTickLabels": true, - "tickLabelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "tickLabelColor": "rgba(0, 0, 0, 0.54)", - "ticksFormatter": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;", - "showTicks": true, - "ticksColor": "rgba(0, 0, 0, 0.54)", - "showLine": true, - "lineColor": "rgba(0, 0, 0, 0.54)", - "showSplitLines": true, - "splitLinesColor": "rgba(0, 0, 0, 0.12)", - "id": "default", - "order": 0, - "min": null, - "max": null - } - }, - "thresholds": [], - "dataZoom": false, - "stack": false, - "xAxis": { - "show": true, - "label": "", - "labelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "600", - "lineHeight": "1" - }, - "labelColor": "rgba(0, 0, 0, 0.54)", - "position": "bottom", - "showTickLabels": true, - "tickLabelFont": { - "family": "Roboto", - "size": 10, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "tickLabelColor": "rgba(0, 0, 0, 0.54)", - "ticksFormat": {}, - "showTicks": true, - "ticksColor": "rgba(0, 0, 0, 0.54)", - "showLine": true, - "lineColor": "rgba(0, 0, 0, 0.54)", - "showSplitLines": true, - "splitLinesColor": "rgba(0, 0, 0, 0.12)" - }, - "noAggregationBarWidthSettings": { - "strategy": "group", - "groupWidth": { - "relative": true, - "relativeWidth": 6, - "absoluteWidth": 1800000 - }, - "barWidth": { - "relative": true, - "relativeWidth": 2, - "absoluteWidth": 1000 - } - }, - "showLegend": true, - "legendLabelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "16px" - }, - "legendLabelColor": "rgba(0, 0, 0, 0.76)", - "legendConfig": { - "direction": "column", - "position": "bottom", - "sortDataKeys": false, - "showMin": false, - "showMax": false, - "showAvg": false, - "showTotal": true, - "showLatest": false, - "valueFormat": null - }, - "showTooltip": true, - "tooltipTrigger": "axis", - "tooltipValueFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "500", - "lineHeight": "16px" - }, - "tooltipValueColor": "rgba(0, 0, 0, 0.76)", - "tooltipShowDate": true, - "tooltipDateFormat": { - "format": "yyyy-MM-dd HH:mm:ss", - "lastUpdateAgo": false, - "custom": false, - "auto": true, - "autoDateFormatSettings": {} - }, - "tooltipDateFont": { - "family": "Roboto", - "size": 11, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "16px" - }, - "tooltipDateColor": "rgba(0, 0, 0, 0.76)", - "tooltipDateInterval": true, - "tooltipBackgroundColor": "rgba(255, 255, 255, 0.76)", - "tooltipBackgroundBlur": 4, - "animation": { - "animation": true, - "animationThreshold": 2000, - "animationDuration": 1000, - "animationEasing": "cubicOut", - "animationDelay": 0, - "animationDurationUpdate": 300, - "animationEasingUpdate": "cubicOut", - "animationDelayUpdate": 0 - }, - "background": { - "type": "color", - "color": "#fff", - "overlay": { - "enabled": false, - "color": "rgba(255,255,255,0.72)", - "blur": 3 - } - }, - "comparisonEnabled": false, - "timeForComparison": "previousInterval", - "comparisonCustomIntervalValue": 7200000, - "comparisonXAxis": { - "show": true, - "label": "", - "labelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "600", - "lineHeight": "1" - }, - "labelColor": "rgba(0, 0, 0, 0.54)", - "position": "top", - "showTickLabels": true, - "tickLabelFont": { - "family": "Roboto", - "size": 10, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "tickLabelColor": "rgba(0, 0, 0, 0.54)", - "ticksFormat": {}, - "showTicks": true, - "ticksColor": "rgba(0, 0, 0, 0.54)", - "showLine": true, - "lineColor": "rgba(0, 0, 0, 0.54)", - "showSplitLines": true, - "splitLinesColor": "rgba(0, 0, 0, 0.12)" - }, - "grid": { - "show": false, - "backgroundColor": null, - "borderWidth": 1, - "borderColor": "#ccc" - }, - "legendColumnTitleFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "16px" - }, - "legendColumnTitleColor": "rgba(0, 0, 0, 0.38)", - "legendValueFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "500", - "lineHeight": "16px" - }, - "legendValueColor": "rgba(0, 0, 0, 0.87)", - "tooltipLabelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "16px" - }, - "tooltipLabelColor": "rgba(0, 0, 0, 0.76)", - "tooltipHideZeroValues": null, - "padding": "12px" - }, - "title": "{i18n:api-usage.transport-data-points-hourly-activity}", - "dropShadow": true, - "enableFullscreen": true, - "titleStyle": null, - "configMode": "basic", - "actions": {}, - "showTitleIcon": false, - "titleIcon": "thermostat", - "iconColor": "#1F6BDD", - "useDashboardTimewindow": false, - "displayTimewindow": true, - "titleFont": { - "size": 16, - "sizeUnit": "px", - "family": "Roboto", - "weight": "500", - "style": "normal", - "lineHeight": "24px" - }, - "titleColor": "rgba(0, 0, 0, 0.87)", - "titleTooltip": "", - "widgetStyle": {}, - "widgetCss": "", - "pageSize": 1024, - "units": "", - "decimals": null, - "noDataDisplayMessage": "", - "timewindowStyle": { - "showIcon": false, - "iconSize": "24px", - "icon": null, - "iconPosition": "left", - "font": { - "size": 12, - "sizeUnit": "px", - "family": "Roboto", - "weight": "400", - "style": "normal", - "lineHeight": "16px" - }, - "color": "rgba(0, 0, 0, 0.38)", - "displayTypePrefix": true - }, - "margin": "0px", - "borderRadius": "4px", - "iconSize": "0px" - }, - "row": 0, - "col": 0, - "id": "9e00cc90-520d-2108-1d2f-bba68ed5cbf1" - }, - "79056202-c92b-1dae-ce49-318ec52e2d3b": { - "typeFullFqn": "system.time_series_chart", - "type": "timeseries", - "sizeX": 8, - "sizeY": 5, - "config": { - "datasources": [ - { - "type": "entity", - "name": null, - "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", - "filterId": null, - "dataKeys": [ - { - "name": "transportDataPointsCountHourly", - "type": "timeseries", - "label": "{i18n:api-usage.transport-data-points}", - "color": "#4CAF50", - "settings": { - "excludeFromStacking": false, - "hideDataByDefault": false, - "disableDataHiding": false, - "removeFromLegend": false, - "showLines": false, - "fillLines": false, - "showPoints": false, - "showPointShape": "circle", - "pointShapeFormatter": "", - "showPointsLineWidth": 5, - "showPointsRadius": 3, - "showSeparateAxis": false, - "axisPosition": "left", - "thresholds": [ - { - "thresholdValueSource": "predefinedValue" - } - ], - "comparisonSettings": { - "showValuesForComparison": true - }, - "type": "bar", - "yAxisId": "default" - }, - "_hash": 0.0661644137210089, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": null, - "postFuncBody": null, - "aggregationType": null - } - ], - "alarmFilterConfig": { - "statusList": [ - "ACTIVE" - ] - } - } - ], - "timewindow": { - "hideAggregation": false, - "hideAggInterval": false, - "hideTimezone": false, - "selectedTab": 1, - "history": { - "historyType": 0, - "timewindowMs": 2592000000, - "interval": 86400000, - "fixedTimewindow": { - "startTimeMs": 1709729389667, - "endTimeMs": 1709815789667 - }, - "quickInterval": "CURRENT_DAY" - }, - "aggregation": { - "type": "SUM", - "limit": 25000 - }, - "timezone": null - }, - "showTitle": true, - "backgroundColor": "#FFFFFF", - "color": "rgba(0, 0, 0, 0.87)", - "padding": "0px", - "settings": { - "yAxes": { - "default": { - "units": null, - "decimals": 0, - "show": true, - "label": "", - "labelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "600", - "lineHeight": "1" - }, - "labelColor": "rgba(0, 0, 0, 0.54)", - "position": "left", - "showTickLabels": true, - "tickLabelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "tickLabelColor": "rgba(0, 0, 0, 0.54)", - "ticksFormatter": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;", - "showTicks": true, - "ticksColor": "rgba(0, 0, 0, 0.54)", - "showLine": true, - "lineColor": "rgba(0, 0, 0, 0.54)", - "showSplitLines": true, - "splitLinesColor": "rgba(0, 0, 0, 0.12)", - "id": "default", - "order": 0, - "min": null, - "max": null - } - }, - "thresholds": [], - "dataZoom": false, - "stack": false, - "xAxis": { - "show": true, - "label": "", - "labelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "600", - "lineHeight": "1" - }, - "labelColor": "rgba(0, 0, 0, 0.54)", - "position": "bottom", - "showTickLabels": true, - "tickLabelFont": { - "family": "Roboto", - "size": 10, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "tickLabelColor": "rgba(0, 0, 0, 0.54)", - "ticksFormat": {}, - "showTicks": true, - "ticksColor": "rgba(0, 0, 0, 0.54)", - "showLine": true, - "lineColor": "rgba(0, 0, 0, 0.54)", - "showSplitLines": true, - "splitLinesColor": "rgba(0, 0, 0, 0.12)" - }, - "noAggregationBarWidthSettings": { - "strategy": "group", - "groupWidth": { - "relative": true, - "relativeWidth": 6, - "absoluteWidth": 1800000 - }, - "barWidth": { - "relative": true, - "relativeWidth": 2, - "absoluteWidth": 1000 - } - }, - "showLegend": true, - "legendLabelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "16px" - }, - "legendLabelColor": "rgba(0, 0, 0, 0.76)", - "legendConfig": { - "direction": "column", - "position": "bottom", - "sortDataKeys": false, - "showMin": false, - "showMax": false, - "showAvg": false, - "showTotal": true, - "showLatest": false, - "valueFormat": null - }, - "showTooltip": true, - "tooltipTrigger": "axis", - "tooltipValueFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "500", - "lineHeight": "16px" - }, - "tooltipValueColor": "rgba(0, 0, 0, 0.76)", - "tooltipShowDate": true, - "tooltipDateFormat": { - "format": "yyyy-MM-dd HH:mm:ss", - "lastUpdateAgo": false, - "custom": false, - "auto": true, - "autoDateFormatSettings": {} - }, - "tooltipDateFont": { - "family": "Roboto", - "size": 11, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "16px" - }, - "tooltipDateColor": "rgba(0, 0, 0, 0.76)", - "tooltipDateInterval": true, - "tooltipBackgroundColor": "rgba(255, 255, 255, 0.76)", - "tooltipBackgroundBlur": 4, - "animation": { - "animation": true, - "animationThreshold": 2000, - "animationDuration": 1000, - "animationEasing": "cubicOut", - "animationDelay": 0, - "animationDurationUpdate": 300, - "animationEasingUpdate": "cubicOut", - "animationDelayUpdate": 0 - }, - "background": { - "type": "color", - "color": "#fff", - "overlay": { - "enabled": false, - "color": "rgba(255,255,255,0.72)", - "blur": 3 - } - }, - "comparisonEnabled": false, - "timeForComparison": "previousInterval", - "comparisonCustomIntervalValue": 7200000, - "comparisonXAxis": { - "show": true, - "label": "", - "labelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "600", - "lineHeight": "1" - }, - "labelColor": "rgba(0, 0, 0, 0.54)", - "position": "top", - "showTickLabels": true, - "tickLabelFont": { - "family": "Roboto", - "size": 10, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "tickLabelColor": "rgba(0, 0, 0, 0.54)", - "ticksFormat": {}, - "showTicks": true, - "ticksColor": "rgba(0, 0, 0, 0.54)", - "showLine": true, - "lineColor": "rgba(0, 0, 0, 0.54)", - "showSplitLines": true, - "splitLinesColor": "rgba(0, 0, 0, 0.12)" - }, - "grid": { - "show": false, - "backgroundColor": null, - "borderWidth": 1, - "borderColor": "#ccc" - }, - "legendColumnTitleFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "16px" - }, - "legendColumnTitleColor": "rgba(0, 0, 0, 0.38)", - "legendValueFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "500", - "lineHeight": "16px" - }, - "legendValueColor": "rgba(0, 0, 0, 0.87)", - "tooltipLabelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "16px" - }, - "tooltipLabelColor": "rgba(0, 0, 0, 0.76)", - "tooltipHideZeroValues": null, - "padding": "12px" - }, - "title": "{i18n:api-usage.transport-data-points-daily-activity}", - "dropShadow": true, - "enableFullscreen": true, - "titleStyle": null, - "configMode": "basic", - "actions": {}, - "showTitleIcon": false, - "titleIcon": "thermostat", - "iconColor": "#1F6BDD", - "useDashboardTimewindow": false, - "displayTimewindow": true, - "titleFont": { - "size": 16, - "sizeUnit": "px", - "family": "Roboto", - "weight": "500", - "style": "normal", - "lineHeight": "24px" - }, - "titleColor": "rgba(0, 0, 0, 0.87)", - "titleTooltip": "", - "widgetStyle": {}, - "widgetCss": "", - "pageSize": 1024, - "units": "", - "decimals": null, - "noDataDisplayMessage": "", - "timewindowStyle": { - "showIcon": false, - "iconSize": "24px", - "icon": null, - "iconPosition": "left", - "font": { - "size": 12, - "sizeUnit": "px", - "family": "Roboto", - "weight": "400", - "style": "normal", - "lineHeight": "16px" - }, - "color": "rgba(0, 0, 0, 0.38)", - "displayTypePrefix": true - }, - "margin": "0px", - "borderRadius": "4px", - "iconSize": "0px" - }, - "row": 0, - "col": 0, - "id": "79056202-c92b-1dae-ce49-318ec52e2d3b" - }, - "966ffee7-ba0d-8e54-f903-e8d015ca8cd2": { - "typeFullFqn": "system.time_series_chart", - "type": "timeseries", - "sizeX": 8, - "sizeY": 5, - "config": { - "datasources": [ - { - "type": "entity", - "name": null, - "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", - "filterId": null, - "dataKeys": [ - { - "name": "transportDataPointsCountHourly", - "type": "timeseries", - "label": "{i18n:api-usage.transport-data-points}", - "color": "#4CAF50", - "settings": { - "yAxisId": "default", - "showInLegend": true, - "dataHiddenByDefault": false, - "type": "bar", - "lineSettings": { - "showLine": true, - "step": false, - "stepType": "start", - "smooth": false, - "lineType": "solid", - "lineWidth": 2, - "showPoints": false, - "showPointLabel": false, - "pointLabelPosition": "top", - "pointLabelFont": { - "family": "Roboto", - "size": 11, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "pointLabelColor": "rgba(0, 0, 0, 0.76)", - "enablePointLabelBackground": false, - "pointLabelBackground": "rgba(255,255,255,0.56)", - "pointShape": "emptyCircle", - "pointSize": 4, - "fillAreaSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } - } - }, - "barSettings": { - "showBorder": false, - "borderWidth": 2, - "borderRadius": 0, - "showLabel": false, - "labelPosition": "top", - "labelFont": { - "family": "Roboto", - "size": 11, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "labelColor": "rgba(0, 0, 0, 0.76)", - "enableLabelBackground": false, - "labelBackground": "rgba(255,255,255,0.56)", - "backgroundSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } - } - }, - "comparisonSettings": { - "showValuesForComparison": false, - "comparisonValuesLabel": "", - "color": "" - } - }, - "_hash": 0.12814821361119078, - "aggregationType": null, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": null, - "postFuncBody": null - } - ], - "alarmFilterConfig": { - "statusList": [ - "ACTIVE" - ] - } - } - ], - "timewindow": { - "hideAggregation": false, - "hideAggInterval": false, - "hideTimezone": false, - "selectedTab": 1, - "realtime": { - "realtimeType": 0, - "interval": 1000, - "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false - }, - "history": { - "historyType": 0, - "interval": 2592000000, - "timewindowMs": 31536000000, - "fixedTimewindow": null, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideFixedInterval": false, - "hideQuickInterval": false - }, - "aggregation": { - "type": "NONE", - "limit": 25000 - }, - "timezone": null - }, - "showTitle": true, - "backgroundColor": "#FFFFFF", - "color": "rgba(0, 0, 0, 0.87)", - "padding": "0px", - "settings": { - "yAxes": { - "default": { - "units": null, - "decimals": 0, - "show": true, - "label": "", - "labelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "600", - "lineHeight": "1" - }, - "labelColor": "rgba(0, 0, 0, 0.54)", - "position": "left", - "showTickLabels": true, - "tickLabelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "tickLabelColor": "rgba(0, 0, 0, 0.54)", - "ticksFormatter": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;", - "showTicks": true, - "ticksColor": "rgba(0, 0, 0, 0.54)", - "showLine": true, - "lineColor": "rgba(0, 0, 0, 0.54)", - "showSplitLines": true, - "splitLinesColor": "rgba(0, 0, 0, 0.12)", - "id": "default", - "order": 0, - "min": null, - "max": null - } - }, - "thresholds": [], - "dataZoom": false, - "stack": false, - "xAxis": { - "show": true, - "label": "", - "labelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "600", - "lineHeight": "1" - }, - "labelColor": "rgba(0, 0, 0, 0.54)", - "position": "bottom", - "showTickLabels": true, - "tickLabelFont": { - "family": "Roboto", - "size": 10, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "tickLabelColor": "rgba(0, 0, 0, 0.54)", - "ticksFormat": {}, - "showTicks": true, - "ticksColor": "rgba(0, 0, 0, 0.54)", - "showLine": true, - "lineColor": "rgba(0, 0, 0, 0.54)", - "showSplitLines": true, - "splitLinesColor": "rgba(0, 0, 0, 0.12)" - }, - "noAggregationBarWidthSettings": { - "strategy": "group", - "groupWidth": { - "relative": true, - "relativeWidth": 6, - "absoluteWidth": 1800000 - }, - "barWidth": { - "relative": true, - "relativeWidth": 2, - "absoluteWidth": 1000 - } - }, - "showLegend": true, - "legendLabelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "16px" - }, - "legendLabelColor": "rgba(0, 0, 0, 0.76)", - "legendConfig": { - "direction": "column", - "position": "bottom", - "sortDataKeys": false, - "showMin": false, - "showMax": false, - "showAvg": false, - "showTotal": true, - "showLatest": false, - "valueFormat": null - }, - "showTooltip": true, - "tooltipTrigger": "axis", - "tooltipValueFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "500", - "lineHeight": "16px" - }, - "tooltipValueColor": "rgba(0, 0, 0, 0.76)", - "tooltipShowDate": true, - "tooltipDateFormat": { - "format": "yyyy-MM-dd HH:mm:ss", - "lastUpdateAgo": false, - "custom": false, - "auto": true, - "autoDateFormatSettings": {} - }, - "tooltipDateFont": { - "family": "Roboto", - "size": 11, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "16px" - }, - "tooltipDateColor": "rgba(0, 0, 0, 0.76)", - "tooltipDateInterval": true, - "tooltipBackgroundColor": "rgba(255, 255, 255, 0.76)", - "tooltipBackgroundBlur": 4, - "animation": { - "animation": true, - "animationThreshold": 2000, - "animationDuration": 1000, - "animationEasing": "cubicOut", - "animationDelay": 0, - "animationDurationUpdate": 300, - "animationEasingUpdate": "cubicOut", - "animationDelayUpdate": 0 - }, - "background": { - "type": "color", - "color": "#fff", - "overlay": { - "enabled": false, - "color": "rgba(255,255,255,0.72)", - "blur": 3 - } - }, - "comparisonEnabled": false, - "timeForComparison": "previousInterval", - "comparisonCustomIntervalValue": 7200000, - "comparisonXAxis": { - "show": true, - "label": "", - "labelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "600", - "lineHeight": "1" - }, - "labelColor": "rgba(0, 0, 0, 0.54)", - "position": "top", - "showTickLabels": true, - "tickLabelFont": { - "family": "Roboto", - "size": 10, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "tickLabelColor": "rgba(0, 0, 0, 0.54)", - "ticksFormat": {}, - "showTicks": true, - "ticksColor": "rgba(0, 0, 0, 0.54)", - "showLine": true, - "lineColor": "rgba(0, 0, 0, 0.54)", - "showSplitLines": true, - "splitLinesColor": "rgba(0, 0, 0, 0.12)" - }, - "grid": { - "show": false, - "backgroundColor": null, - "borderWidth": 1, - "borderColor": "#ccc" - }, - "legendColumnTitleFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "16px" - }, - "legendColumnTitleColor": "rgba(0, 0, 0, 0.38)", - "legendValueFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "500", - "lineHeight": "16px" - }, - "legendValueColor": "rgba(0, 0, 0, 0.87)", - "tooltipLabelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "16px" - }, - "tooltipLabelColor": "rgba(0, 0, 0, 0.76)", - "tooltipHideZeroValues": null, - "padding": "12px" - }, - "title": "{i18n:api-usage.transport-data-points-monthly-activity}", - "dropShadow": true, - "enableFullscreen": true, - "titleStyle": null, - "configMode": "basic", - "actions": {}, - "showTitleIcon": false, - "titleIcon": "thermostat", - "iconColor": "#1F6BDD", - "useDashboardTimewindow": false, - "displayTimewindow": true, - "titleFont": { - "size": 16, - "sizeUnit": "px", - "family": "Roboto", - "weight": "500", - "style": "normal", - "lineHeight": "24px" - }, - "titleColor": "rgba(0, 0, 0, 0.87)", - "titleTooltip": "", - "widgetStyle": {}, - "widgetCss": "", - "pageSize": 1024, - "units": "", - "decimals": null, - "noDataDisplayMessage": "", - "timewindowStyle": { - "showIcon": false, - "iconSize": "24px", - "icon": null, - "iconPosition": "left", - "font": { - "size": 12, - "sizeUnit": "px", - "family": "Roboto", - "weight": "400", - "style": "normal", - "lineHeight": "16px" - }, - "color": "rgba(0, 0, 0, 0.38)", - "displayTypePrefix": true - }, - "margin": "0px", - "borderRadius": "4px", - "iconSize": "0px" - }, - "row": 0, - "col": 0, - "id": "966ffee7-ba0d-8e54-f903-e8d015ca8cd2" - }, - "b1a9a51f-e5a6-9d5f-ef5c-25c2a68af1b0": { - "typeFullFqn": "system.time_series_chart", - "type": "timeseries", - "sizeX": 8, - "sizeY": 5, - "config": { - "datasources": [ - { - "type": "entity", - "name": null, - "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", - "filterId": null, - "dataKeys": [ - { - "name": "ruleEngineExecutionCountHourly", - "type": "timeseries", - "label": "{i18n:api-usage.rule-engine-executions}", - "color": "#AB00FF", - "settings": { - "yAxisId": "default", - "showInLegend": true, - "dataHiddenByDefault": false, - "type": "bar", - "lineSettings": { - "showLine": true, - "step": false, - "stepType": "start", - "smooth": false, - "lineType": "solid", - "lineWidth": 2, - "showPoints": false, - "showPointLabel": false, - "pointLabelPosition": "top", - "pointLabelFont": { - "family": "Roboto", - "size": 11, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "pointLabelColor": "rgba(0, 0, 0, 0.76)", - "enablePointLabelBackground": false, - "pointLabelBackground": "rgba(255,255,255,0.56)", - "pointShape": "emptyCircle", - "pointSize": 4, - "fillAreaSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } - } - }, - "barSettings": { - "showBorder": false, - "borderWidth": 2, - "borderRadius": 0, - "showLabel": false, - "labelPosition": "top", - "labelFont": { - "family": "Roboto", - "size": 11, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "labelColor": "rgba(0, 0, 0, 0.76)", - "enableLabelBackground": false, - "labelBackground": "rgba(255,255,255,0.56)", - "backgroundSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } - } - }, - "comparisonSettings": { - "showValuesForComparison": false, - "comparisonValuesLabel": "", - "color": "" - } - }, - "_hash": 0.5078724779454146, - "aggregationType": null, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": null, - "postFuncBody": null - } - ], - "alarmFilterConfig": { - "statusList": [ - "ACTIVE" - ] - } - } - ], - "timewindow": { - "hideAggregation": false, - "hideAggInterval": false, - "hideTimezone": false, - "selectedTab": 0, - "realtime": { - "realtimeType": 0, - "interval": 3600000, - "timewindowMs": 86400000, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false - }, - "history": { - "historyType": 0, - "interval": 86400000, - "timewindowMs": 2592000000, - "fixedTimewindow": null, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideFixedInterval": false, - "hideQuickInterval": false - }, - "aggregation": { - "type": "SUM", - "limit": 25000 - }, - "timezone": null - }, - "showTitle": true, - "backgroundColor": "#FFFFFF", - "color": "rgba(0, 0, 0, 0.87)", - "padding": "0px", - "settings": { - "yAxes": { - "default": { - "units": null, - "decimals": 0, - "show": true, - "label": "", - "labelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "600", - "lineHeight": "1" - }, - "labelColor": "rgba(0, 0, 0, 0.54)", - "position": "left", - "showTickLabels": true, - "tickLabelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "tickLabelColor": "rgba(0, 0, 0, 0.54)", - "ticksFormatter": "var rounder = Math.pow(10, 1);\nvar powers = [\n {key: 'Q', value: Math.pow(10, 15)},\n {key: 'T', value: Math.pow(10, 12)},\n {key: 'B', value: Math.pow(10, 9)},\n {key: 'M', value: Math.pow(10, 6)},\n {key: 'K', value: 1000}\n];\n\nvar key = '';\n\nfor (var i = 0; i < powers.length; i++) {\n var reduced = value / powers[i].value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n value = reduced;\n key = powers[i].key;\n break;\n }\n}\nreturn value + key;", - "showTicks": true, - "ticksColor": "rgba(0, 0, 0, 0.54)", - "showLine": true, - "lineColor": "rgba(0, 0, 0, 0.54)", - "showSplitLines": true, - "splitLinesColor": "rgba(0, 0, 0, 0.12)", - "id": "default", - "order": 0, - "min": null, - "max": null - } - }, - "thresholds": [], - "dataZoom": false, - "stack": false, - "xAxis": { - "show": true, - "label": "", - "labelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "600", - "lineHeight": "1" - }, - "labelColor": "rgba(0, 0, 0, 0.54)", - "position": "bottom", - "showTickLabels": true, - "tickLabelFont": { - "family": "Roboto", - "size": 10, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "tickLabelColor": "rgba(0, 0, 0, 0.54)", - "ticksFormat": {}, - "showTicks": true, - "ticksColor": "rgba(0, 0, 0, 0.54)", - "showLine": true, - "lineColor": "rgba(0, 0, 0, 0.54)", - "showSplitLines": true, - "splitLinesColor": "rgba(0, 0, 0, 0.12)" - }, - "noAggregationBarWidthSettings": { - "strategy": "group", - "groupWidth": { - "relative": true, - "relativeWidth": 6, - "absoluteWidth": 1800000 - }, - "barWidth": { - "relative": true, - "relativeWidth": 2, - "absoluteWidth": 1000 - } - }, - "showLegend": true, - "legendLabelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "16px" - }, - "legendLabelColor": "rgba(0, 0, 0, 0.76)", - "legendConfig": { - "direction": "column", - "position": "bottom", - "sortDataKeys": false, - "showMin": false, - "showMax": false, - "showAvg": false, - "showTotal": true, - "showLatest": false, - "valueFormat": null - }, - "showTooltip": true, - "tooltipTrigger": "axis", - "tooltipValueFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "500", - "lineHeight": "16px" - }, - "tooltipValueColor": "rgba(0, 0, 0, 0.76)", - "tooltipShowDate": true, - "tooltipDateFormat": { - "format": "yyyy-MM-dd HH:mm:ss", - "lastUpdateAgo": false, - "custom": false, - "auto": true, - "autoDateFormatSettings": {} - }, - "tooltipDateFont": { - "family": "Roboto", - "size": 11, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "16px" - }, - "tooltipDateColor": "rgba(0, 0, 0, 0.76)", - "tooltipDateInterval": true, - "tooltipBackgroundColor": "rgba(255, 255, 255, 0.76)", - "tooltipBackgroundBlur": 4, - "animation": { - "animation": true, - "animationThreshold": 2000, - "animationDuration": 1000, - "animationEasing": "cubicOut", - "animationDelay": 0, - "animationDurationUpdate": 300, - "animationEasingUpdate": "cubicOut", - "animationDelayUpdate": 0 - }, - "background": { - "type": "color", - "color": "#fff", - "overlay": { - "enabled": false, - "color": "rgba(255,255,255,0.72)", - "blur": 3 - } - }, - "comparisonEnabled": false, - "timeForComparison": "previousInterval", - "comparisonCustomIntervalValue": 7200000, - "comparisonXAxis": { - "show": true, - "label": "", - "labelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "600", - "lineHeight": "1" - }, - "labelColor": "rgba(0, 0, 0, 0.54)", - "position": "top", - "showTickLabels": true, - "tickLabelFont": { - "family": "Roboto", - "size": 10, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "1" - }, - "tickLabelColor": "rgba(0, 0, 0, 0.54)", - "ticksFormat": {}, - "showTicks": true, - "ticksColor": "rgba(0, 0, 0, 0.54)", - "showLine": true, - "lineColor": "rgba(0, 0, 0, 0.54)", - "showSplitLines": true, - "splitLinesColor": "rgba(0, 0, 0, 0.12)" - }, - "grid": { - "show": false, - "backgroundColor": null, - "borderWidth": 1, - "borderColor": "#ccc" - }, - "legendColumnTitleFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "16px" - }, - "legendColumnTitleColor": "rgba(0, 0, 0, 0.38)", - "legendValueFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "500", - "lineHeight": "16px" - }, - "legendValueColor": "rgba(0, 0, 0, 0.87)", - "tooltipLabelFont": { - "family": "Roboto", - "size": 12, - "sizeUnit": "px", - "style": "normal", - "weight": "400", - "lineHeight": "16px" - }, - "tooltipLabelColor": "rgba(0, 0, 0, 0.76)", - "tooltipHideZeroValues": null, - "padding": "12px" - }, - "title": "{i18n:api-usage.rule-engine-hourly-activity}", - "dropShadow": true, - "enableFullscreen": true, - "titleStyle": null, - "configMode": "basic", - "actions": { - "headerButton": [ - { - "name": "{i18n:api-usage.view-statistics}", - "buttonType": "icon", - "icon": "show_chart", - "buttonColor": "rgba(0, 0, 0, 0.87)", - "customButtonStyle": {}, - "useShowWidgetActionFunction": null, - "showWidgetActionFunction": "return true;", - "type": "openDashboardState", - "targetDashboardStateId": "rule_engine_statistics", - "setEntityId": true, - "stateEntityParamName": null, - "openRightLayout": false, - "openInSeparateDialog": false, - "openInPopover": false, - "id": "8b57e118-84fc-4add-2536-d3cfde018b83" - } - ] - }, - "showTitleIcon": false, - "titleIcon": "thermostat", - "iconColor": "#1F6BDD", - "useDashboardTimewindow": false, - "displayTimewindow": true, - "titleFont": { - "size": 16, - "sizeUnit": "px", - "family": "Roboto", - "weight": "500", - "style": "normal", - "lineHeight": "24px" - }, - "titleColor": "rgba(0, 0, 0, 0.87)", - "titleTooltip": "", - "widgetStyle": {}, - "widgetCss": "", - "pageSize": 1024, - "units": "", - "decimals": null, - "noDataDisplayMessage": "", - "timewindowStyle": { - "showIcon": false, - "iconSize": "24px", - "icon": null, - "iconPosition": "left", - "font": { - "size": 12, - "sizeUnit": "px", - "family": "Roboto", - "weight": "400", - "style": "normal", - "lineHeight": "16px" - }, - "color": "rgba(0, 0, 0, 0.38)", - "displayTypePrefix": true - }, - "margin": "0px", - "borderRadius": "4px", - "iconSize": "0px" - }, - "row": 0, - "col": 0, - "id": "b1a9a51f-e5a6-9d5f-ef5c-25c2a68af1b0" - }, - "84fbe63a-bcb6-7bc1-8af0-46b3b1ee5adc": { - "typeFullFqn": "system.time_series_chart", - "type": "timeseries", - "sizeX": 8, - "sizeY": 5, - "config": { - "datasources": [ - { - "type": "entity", - "name": null, - "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", - "filterId": null, - "dataKeys": [ - { - "name": "ruleEngineExecutionCountHourly", - "type": "timeseries", - "label": "{i18n:api-usage.rule-engine-executions}", - "color": "#AB00FF", - "settings": { - "yAxisId": "default", - "showInLegend": true, - "dataHiddenByDefault": false, + "yAxisId": "default", + "showInLegend": true, + "dataHiddenByDefault": false, "type": "bar", "lineSettings": { "showLine": true, @@ -5508,7 +3835,7 @@ "color": "" } }, - "_hash": 0.01948850513940492, + "_hash": 0.12814821361119078, "aggregationType": null, "units": null, "decimals": null, @@ -5529,18 +3856,28 @@ "hideAggInterval": false, "hideTimezone": false, "selectedTab": 1, + "realtime": { + "realtimeType": 0, + "interval": 1000, + "timewindowMs": 60000, + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideQuickInterval": false + }, "history": { "historyType": 0, - "timewindowMs": 2592000000, - "interval": 86400000, - "fixedTimewindow": { - "startTimeMs": 1709729389667, - "endTimeMs": 1709815789667 - }, - "quickInterval": "CURRENT_DAY" + "interval": 2592000000, + "timewindowMs": 31536000000, + "fixedTimewindow": null, + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideFixedInterval": false, + "hideQuickInterval": false }, "aggregation": { - "type": "SUM", + "type": "NONE", "limit": 25000 }, "timezone": null @@ -5777,32 +4114,12 @@ "tooltipHideZeroValues": null, "padding": "12px" }, - "title": "{i18n:api-usage.rule-engine-daily-activity}", + "title": "{i18n:api-usage.transport-data-points-monthly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, "configMode": "basic", - "actions": { - "headerButton": [ - { - "name": "{i18n:api-usage.view-statistics}", - "buttonType": "icon", - "icon": "show_chart", - "buttonColor": "rgba(0, 0, 0, 0.87)", - "customButtonStyle": {}, - "useShowWidgetActionFunction": null, - "showWidgetActionFunction": "return true;", - "type": "openDashboardState", - "targetDashboardStateId": "rule_engine_statistics", - "setEntityId": true, - "stateEntityParamName": null, - "openRightLayout": false, - "openInSeparateDialog": false, - "openInPopover": false, - "id": "2592147a-3f62-987a-78c0-cdb775fb4233" - } - ] - }, + "actions": {}, "showTitleIcon": false, "titleIcon": "thermostat", "iconColor": "#1F6BDD", @@ -5846,9 +4163,9 @@ }, "row": 0, "col": 0, - "id": "84fbe63a-bcb6-7bc1-8af0-46b3b1ee5adc" + "id": "966ffee7-ba0d-8e54-f903-e8d015ca8cd2" }, - "43a2b982-6c02-d9bd-71ee-34e8e6cf8893": { + "84fbe63a-bcb6-7bc1-8af0-46b3b1ee5adc": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -5862,7 +4179,7 @@ "filterId": null, "dataKeys": [ { - "name": "ruleEngineExecutionCount", + "name": "ruleEngineExecutionCountHourly", "type": "timeseries", "label": "{i18n:api-usage.rule-engine-executions}", "color": "#AB00FF", @@ -5935,7 +4252,7 @@ "color": "" } }, - "_hash": 0.5125470598651091, + "_hash": 0.01948850513940492, "aggregationType": null, "units": null, "decimals": null, @@ -5955,29 +4272,19 @@ "hideAggregation": false, "hideAggInterval": false, "hideTimezone": false, - "selectedTab": 1, - "realtime": { - "realtimeType": 0, - "interval": 1000, - "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false - }, - "history": { - "historyType": 0, - "interval": 2592000000, - "timewindowMs": 31536000000, - "fixedTimewindow": null, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideFixedInterval": false, - "hideQuickInterval": false + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 2592000000, + "interval": 86400000, + "fixedTimewindow": { + "startTimeMs": 1709729389667, + "endTimeMs": 1709815789667 + }, + "quickInterval": "CURRENT_DAY" }, "aggregation": { - "type": "NONE", + "type": "SUM", "limit": 25000 }, "timezone": null @@ -6214,7 +4521,7 @@ "tooltipHideZeroValues": null, "padding": "12px" }, - "title": "{i18n:api-usage.rule-engine-monthly-activity}", + "title": "{i18n:api-usage.rule-engine-daily-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -6236,7 +4543,7 @@ "openRightLayout": false, "openInSeparateDialog": false, "openInPopover": false, - "id": "b6ba96cf-48b8-f40f-f010-10b95e7dc819" + "id": "2592147a-3f62-987a-78c0-cdb775fb4233" } ] }, @@ -6283,9 +4590,9 @@ }, "row": 0, "col": 0, - "id": "43a2b982-6c02-d9bd-71ee-34e8e6cf8893" + "id": "84fbe63a-bcb6-7bc1-8af0-46b3b1ee5adc" }, - "76fe83c9-c30f-00a5-6299-40c759ca6705": { + "43a2b982-6c02-d9bd-71ee-34e8e6cf8893": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -6299,42 +4606,86 @@ "filterId": null, "dataKeys": [ { - "name": "jsExecutionCountHourly", + "name": "ruleEngineExecutionCount", "type": "timeseries", - "label": "{i18n:api-usage.javascript-function-executions}", - "color": "#FF9900", + "label": "{i18n:api-usage.rule-engine-executions}", + "color": "#AB00FF", "settings": { - "excludeFromStacking": false, - "hideDataByDefault": false, - "disableDataHiding": false, - "removeFromLegend": false, - "showLines": false, - "fillLines": false, - "showPoints": false, - "showPointShape": "circle", - "pointShapeFormatter": "", - "showPointsLineWidth": 5, - "showPointsRadius": 3, - "showSeparateAxis": false, - "axisPosition": "left", - "thresholds": [ - { - "thresholdValueSource": "predefinedValue" + "yAxisId": "default", + "showInLegend": true, + "dataHiddenByDefault": false, + "type": "bar", + "lineSettings": { + "showLine": true, + "step": false, + "stepType": "start", + "smooth": false, + "lineType": "solid", + "lineWidth": 2, + "showPoints": false, + "showPointLabel": false, + "pointLabelPosition": "top", + "pointLabelFont": { + "family": "Roboto", + "size": 11, + "sizeUnit": "px", + "style": "normal", + "weight": "400", + "lineHeight": "1" + }, + "pointLabelColor": "rgba(0, 0, 0, 0.76)", + "enablePointLabelBackground": false, + "pointLabelBackground": "rgba(255,255,255,0.56)", + "pointShape": "emptyCircle", + "pointSize": 4, + "fillAreaSettings": { + "type": "none", + "opacity": 0.4, + "gradient": { + "start": 100, + "end": 0 + } } - ], - "comparisonSettings": { - "showValuesForComparison": true }, - "type": "bar", - "yAxisId": "default" + "barSettings": { + "showBorder": false, + "borderWidth": 2, + "borderRadius": 0, + "showLabel": false, + "labelPosition": "top", + "labelFont": { + "family": "Roboto", + "size": 11, + "sizeUnit": "px", + "style": "normal", + "weight": "400", + "lineHeight": "1" + }, + "labelColor": "rgba(0, 0, 0, 0.76)", + "enableLabelBackground": false, + "labelBackground": "rgba(255,255,255,0.56)", + "backgroundSettings": { + "type": "none", + "opacity": 0.4, + "gradient": { + "start": 100, + "end": 0 + } + } + }, + "comparisonSettings": { + "showValuesForComparison": false, + "comparisonValuesLabel": "", + "color": "" + } }, - "_hash": 0.0661644137210089, + "_hash": 0.5125470598651091, + "aggregationType": null, "units": null, "decimals": null, "funcBody": null, "usePostProcessing": null, - "postFuncBody": null, - "aggregationType": null + "postFuncBody": null } ], "alarmFilterConfig": { @@ -6348,11 +4699,11 @@ "hideAggregation": false, "hideAggInterval": false, "hideTimezone": false, - "selectedTab": 0, + "selectedTab": 1, "realtime": { "realtimeType": 0, - "interval": 3600000, - "timewindowMs": 86400000, + "interval": 1000, + "timewindowMs": 60000, "quickInterval": "CURRENT_DAY", "hideInterval": false, "hideLastInterval": false, @@ -6360,8 +4711,8 @@ }, "history": { "historyType": 0, - "interval": 86400000, - "timewindowMs": 2592000000, + "interval": 2592000000, + "timewindowMs": 31536000000, "fixedTimewindow": null, "quickInterval": "CURRENT_DAY", "hideInterval": false, @@ -6370,7 +4721,7 @@ "hideQuickInterval": false }, "aggregation": { - "type": "SUM", + "type": "NONE", "limit": 25000 }, "timezone": null @@ -6607,12 +4958,32 @@ "tooltipHideZeroValues": null, "padding": "12px" }, - "title": "{i18n:api-usage.javascript-function-executions-hourly-activity}", + "title": "{i18n:api-usage.rule-engine-monthly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, "configMode": "basic", - "actions": {}, + "actions": { + "headerButton": [ + { + "name": "{i18n:api-usage.view-statistics}", + "buttonType": "icon", + "icon": "show_chart", + "buttonColor": "rgba(0, 0, 0, 0.87)", + "customButtonStyle": {}, + "useShowWidgetActionFunction": null, + "showWidgetActionFunction": "return true;", + "type": "openDashboardState", + "targetDashboardStateId": "rule_engine_statistics", + "setEntityId": true, + "stateEntityParamName": null, + "openRightLayout": false, + "openInSeparateDialog": false, + "openInPopover": false, + "id": "b6ba96cf-48b8-f40f-f010-10b95e7dc819" + } + ] + }, "showTitleIcon": false, "titleIcon": "thermostat", "iconColor": "#1F6BDD", @@ -6656,9 +5027,9 @@ }, "row": 0, "col": 0, - "id": "76fe83c9-c30f-00a5-6299-40c759ca6705" + "id": "43a2b982-6c02-d9bd-71ee-34e8e6cf8893" }, - "a43598d1-7bfd-f329-ee61-c343f34f069f": { + "76fe83c9-c30f-00a5-6299-40c759ca6705": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -6718,25 +5089,16 @@ } ], "timewindow": { - "hideAggregation": false, - "hideAggInterval": false, - "hideTimezone": false, - "selectedTab": 1, - "history": { - "historyType": 0, - "timewindowMs": 2592000000, - "interval": 86400000, - "fixedTimewindow": { - "startTimeMs": 1709729389667, - "endTimeMs": 1709815789667 - }, - "quickInterval": "CURRENT_DAY" + "selectedTab": 0, + "realtime": { + "realtimeType": 0, + "interval": 3600000, + "timewindowMs": 86400000 }, "aggregation": { - "type": "SUM", - "limit": 25000 - }, - "timezone": null + "type": "NONE", + "limit": 50000 + } }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -6970,7 +5332,7 @@ "tooltipHideZeroValues": null, "padding": "12px" }, - "title": "{i18n:api-usage.javascript-function-executions-daily-activity}", + "title": "{i18n:api-usage.javascript-function-executions-hourly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -7019,9 +5381,9 @@ }, "row": 0, "col": 0, - "id": "a43598d1-7bfd-f329-ee61-c343f34f069f" + "id": "76fe83c9-c30f-00a5-6299-40c759ca6705" }, - "3ebd62a8-dcb7-c96b-8571-e61084248f5b": { + "a43598d1-7bfd-f329-ee61-c343f34f069f": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -7035,7 +5397,7 @@ "filterId": null, "dataKeys": [ { - "name": "jsExecutionCount", + "name": "jsExecutionCountHourly", "type": "timeseries", "label": "{i18n:api-usage.javascript-function-executions}", "color": "#FF9900", @@ -7072,7 +5434,12 @@ "postFuncBody": null, "aggregationType": null } - ] + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } } ], "timewindow": { @@ -7080,28 +5447,18 @@ "hideAggInterval": false, "hideTimezone": false, "selectedTab": 1, - "realtime": { - "realtimeType": 0, - "interval": 1000, - "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false - }, "history": { "historyType": 0, - "interval": 2592000000, - "timewindowMs": 31536000000, - "fixedTimewindow": null, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideFixedInterval": false, - "hideQuickInterval": false + "timewindowMs": 2592000000, + "interval": 86400000, + "fixedTimewindow": { + "startTimeMs": 1709729389667, + "endTimeMs": 1709815789667 + }, + "quickInterval": "CURRENT_DAY" }, "aggregation": { - "type": "NONE", + "type": "SUM", "limit": 25000 }, "timezone": null @@ -7338,7 +5695,7 @@ "tooltipHideZeroValues": null, "padding": "12px" }, - "title": "{i18n:api-usage.javascript-function-executions-monthly-activity}", + "title": "{i18n:api-usage.javascript-function-executions-daily-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -7387,9 +5744,9 @@ }, "row": 0, "col": 0, - "id": "3ebd62a8-dcb7-c96b-8571-e61084248f5b" + "id": "a43598d1-7bfd-f329-ee61-c343f34f069f" }, - "88e25971-e5cb-eebb-3c7c-1ce33a8a38f4": { + "3ebd62a8-dcb7-c96b-8571-e61084248f5b": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -7403,10 +5760,10 @@ "filterId": null, "dataKeys": [ { - "name": "tbelExecutionCountHourly", + "name": "jsExecutionCount", "type": "timeseries", - "label": "{i18n:api-usage.tbel-function-executions}", - "color": "#4CAF50", + "label": "{i18n:api-usage.javascript-function-executions}", + "color": "#FF9900", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -7440,23 +5797,18 @@ "postFuncBody": null, "aggregationType": null } - ], - "alarmFilterConfig": { - "statusList": [ - "ACTIVE" - ] - } + ] } ], "timewindow": { "hideAggregation": false, "hideAggInterval": false, "hideTimezone": false, - "selectedTab": 0, + "selectedTab": 1, "realtime": { "realtimeType": 0, - "interval": 3600000, - "timewindowMs": 86400000, + "interval": 1000, + "timewindowMs": 60000, "quickInterval": "CURRENT_DAY", "hideInterval": false, "hideLastInterval": false, @@ -7464,8 +5816,8 @@ }, "history": { "historyType": 0, - "interval": 86400000, - "timewindowMs": 2592000000, + "interval": 2592000000, + "timewindowMs": 31536000000, "fixedTimewindow": null, "quickInterval": "CURRENT_DAY", "hideInterval": false, @@ -7474,7 +5826,7 @@ "hideQuickInterval": false }, "aggregation": { - "type": "SUM", + "type": "NONE", "limit": 25000 }, "timezone": null @@ -7711,7 +6063,7 @@ "tooltipHideZeroValues": null, "padding": "12px" }, - "title": "{i18n:api-usage.tbel-function-executions-hourly-activity}", + "title": "{i18n:api-usage.javascript-function-executions-monthly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -7760,9 +6112,9 @@ }, "row": 0, "col": 0, - "id": "88e25971-e5cb-eebb-3c7c-1ce33a8a38f4" + "id": "3ebd62a8-dcb7-c96b-8571-e61084248f5b" }, - "a1b5731c-e3b3-8cfb-7c50-3abcdce891d2": { + "88e25971-e5cb-eebb-3c7c-1ce33a8a38f4": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -7822,25 +6174,16 @@ } ], "timewindow": { - "hideAggregation": false, - "hideAggInterval": false, - "hideTimezone": false, - "selectedTab": 1, - "history": { - "historyType": 0, - "timewindowMs": 2592000000, - "interval": 86400000, - "fixedTimewindow": { - "startTimeMs": 1709729389667, - "endTimeMs": 1709815789667 - }, - "quickInterval": "CURRENT_DAY" + "selectedTab": 0, + "realtime": { + "realtimeType": 0, + "interval": 3600000, + "timewindowMs": 86400000 }, "aggregation": { - "type": "SUM", - "limit": 25000 - }, - "timezone": null + "type": "NONE", + "limit": 50000 + } }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -8074,7 +6417,7 @@ "tooltipHideZeroValues": null, "padding": "12px" }, - "title": "{i18n:api-usage.tbel-function-executions-daily-activity}", + "title": "{i18n:api-usage.tbel-function-executions-hourly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -8123,9 +6466,9 @@ }, "row": 0, "col": 0, - "id": "a1b5731c-e3b3-8cfb-7c50-3abcdce891d2" + "id": "88e25971-e5cb-eebb-3c7c-1ce33a8a38f4" }, - "efc8d4e9-dee2-b677-c378-c1a666543bf4": { + "a1b5731c-e3b3-8cfb-7c50-3abcdce891d2": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -8139,7 +6482,7 @@ "filterId": null, "dataKeys": [ { - "name": "tbelExecutionCount", + "name": "tbelExecutionCountHourly", "type": "timeseries", "label": "{i18n:api-usage.tbel-function-executions}", "color": "#4CAF50", @@ -8176,7 +6519,12 @@ "postFuncBody": null, "aggregationType": null } - ] + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } } ], "timewindow": { @@ -8184,28 +6532,18 @@ "hideAggInterval": false, "hideTimezone": false, "selectedTab": 1, - "realtime": { - "realtimeType": 0, - "interval": 1000, - "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false - }, "history": { "historyType": 0, - "interval": 2592000000, - "timewindowMs": 31536000000, - "fixedTimewindow": null, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideFixedInterval": false, - "hideQuickInterval": false + "timewindowMs": 2592000000, + "interval": 86400000, + "fixedTimewindow": { + "startTimeMs": 1709729389667, + "endTimeMs": 1709815789667 + }, + "quickInterval": "CURRENT_DAY" }, "aggregation": { - "type": "NONE", + "type": "SUM", "limit": 25000 }, "timezone": null @@ -8442,7 +6780,7 @@ "tooltipHideZeroValues": null, "padding": "12px" }, - "title": "{i18n:api-usage.tbel-function-executions-monthly-activity}", + "title": "{i18n:api-usage.tbel-function-executions-daily-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -8491,9 +6829,9 @@ }, "row": 0, "col": 0, - "id": "efc8d4e9-dee2-b677-c378-c1a666543bf4" + "id": "a1b5731c-e3b3-8cfb-7c50-3abcdce891d2" }, - "61a23bd5-329f-aae7-3168-8a14a51dc10b": { + "efc8d4e9-dee2-b677-c378-c1a666543bf4": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -8507,10 +6845,10 @@ "filterId": null, "dataKeys": [ { - "name": "storageDataPointsCountHourly", + "name": "tbelExecutionCount", "type": "timeseries", - "label": "{i18n:api-usage.data-points-storage-days}", - "color": "#1039EE", + "label": "{i18n:api-usage.tbel-function-executions}", + "color": "#4CAF50", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -8544,23 +6882,18 @@ "postFuncBody": null, "aggregationType": null } - ], - "alarmFilterConfig": { - "statusList": [ - "ACTIVE" - ] - } + ] } ], "timewindow": { "hideAggregation": false, "hideAggInterval": false, "hideTimezone": false, - "selectedTab": 0, + "selectedTab": 1, "realtime": { "realtimeType": 0, - "interval": 3600000, - "timewindowMs": 86400000, + "interval": 1000, + "timewindowMs": 60000, "quickInterval": "CURRENT_DAY", "hideInterval": false, "hideLastInterval": false, @@ -8568,8 +6901,8 @@ }, "history": { "historyType": 0, - "interval": 86400000, - "timewindowMs": 2592000000, + "interval": 2592000000, + "timewindowMs": 31536000000, "fixedTimewindow": null, "quickInterval": "CURRENT_DAY", "hideInterval": false, @@ -8578,7 +6911,7 @@ "hideQuickInterval": false }, "aggregation": { - "type": "SUM", + "type": "NONE", "limit": 25000 }, "timezone": null @@ -8815,7 +7148,7 @@ "tooltipHideZeroValues": null, "padding": "12px" }, - "title": "{i18n:api-usage.data-points-storage-days-hourly-activity}", + "title": "{i18n:api-usage.tbel-function-executions-monthly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -8864,7 +7197,7 @@ }, "row": 0, "col": 0, - "id": "61a23bd5-329f-aae7-3168-8a14a51dc10b" + "id": "efc8d4e9-dee2-b677-c378-c1a666543bf4" }, "1249d3e2-6b3a-4e4a-65e9-6ed22959871e": { "typeFullFqn": "system.time_series_chart", @@ -9662,35 +7995,16 @@ } ], "timewindow": { - "hideAggregation": false, - "hideAggInterval": false, - "hideTimezone": false, "selectedTab": 0, "realtime": { "realtimeType": 0, "interval": 3600000, - "timewindowMs": 86400000, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false - }, - "history": { - "historyType": 0, - "interval": 86400000, - "timewindowMs": 2592000000, - "fixedTimewindow": null, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideFixedInterval": false, - "hideQuickInterval": false + "timewindowMs": 86400000 }, "aggregation": { - "type": "SUM", - "limit": 25000 - }, - "timezone": null + "type": "NONE", + "limit": 50000 + } }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -10771,35 +9085,16 @@ } ], "timewindow": { - "hideAggregation": false, - "hideAggInterval": false, - "hideTimezone": false, "selectedTab": 0, "realtime": { "realtimeType": 0, "interval": 3600000, - "timewindowMs": 86400000, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false - }, - "history": { - "historyType": 0, - "interval": 86400000, - "timewindowMs": 2592000000, - "fixedTimewindow": null, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideFixedInterval": false, - "hideQuickInterval": false + "timewindowMs": 86400000 }, "aggregation": { - "type": "SUM", - "limit": 25000 - }, - "timezone": null + "type": "NONE", + "limit": 50000 + } }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -11875,35 +10170,16 @@ } ], "timewindow": { - "hideAggregation": false, - "hideAggInterval": false, - "hideTimezone": false, "selectedTab": 0, "realtime": { "realtimeType": 0, "interval": 3600000, - "timewindowMs": 86400000, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false - }, - "history": { - "historyType": 0, - "interval": 86400000, - "timewindowMs": 2592000000, - "fixedTimewindow": null, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideFixedInterval": false, - "hideQuickInterval": false + "timewindowMs": 86400000 }, "aggregation": { - "type": "SUM", - "limit": 25000 - }, - "timezone": null + "type": "NONE", + "limit": 50000 + } }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -12932,44 +11208,13 @@ "dataKeys": [] } ], - "timewindow": { - "displayValue": "", - "selectedTab": 0, - "realtime": { - "realtimeType": 1, - "interval": 1000, - "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false - }, - "history": { - "historyType": 0, - "interval": 1000, - "timewindowMs": 60000, - "fixedTimewindow": { - "startTimeMs": 1756302747649, - "endTimeMs": 1756389147649 - }, - "quickInterval": "CURRENT_DAY", - "hideInterval": false, - "hideLastInterval": false, - "hideFixedInterval": false, - "hideQuickInterval": false - }, - "aggregation": { - "type": "AVG", - "limit": 25000 - } - }, "showTitle": true, "backgroundColor": "#fff", "color": "rgba(0, 0, 0, 0.87)", "padding": "0", "settings": { "dsEntityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", - "dataKeys": [ + "apiUsageDataKeys": [ { "label": "{i18n:api-usage.transport-messages}", "state": "transport_messages", @@ -13222,9 +11467,9 @@ "actions": { "headerButton": [ { - "name": "Go back", + "name": "{i18n:widgets.api-usage.go-to-main-state}", "buttonType": "stroked", - "showIcon": true, + "showIcon": false, "icon": "undo", "buttonColor": "#305680", "buttonBorderColor": "#0000001F", @@ -13432,14 +11677,6 @@ }, "right": { "widgets": { - "51608a74-f213-d8c9-8df8-b42238ef93a6": { - "sizeX": 12, - "sizeY": 4, - "row": 0, - "col": 0, - "resizable": true, - "mobileHeight": 6 - }, "fb155957-1af4-233e-e2fb-09e648e75d6e": { "sizeX": 6, "sizeY": 4, @@ -13455,6 +11692,13 @@ "col": 6, "resizable": true, "mobileHeight": 6 + }, + "85240e8c-7af7-90a9-ad0a-726013c479a6": { + "sizeX": 12, + "sizeY": 4, + "resizable": true, + "row": 0, + "col": 0 } }, "gridSettings": { @@ -13511,14 +11755,6 @@ }, "right": { "widgets": { - "9e00cc90-520d-2108-1d2f-bba68ed5cbf1": { - "sizeX": 12, - "sizeY": 4, - "row": 0, - "col": 0, - "resizable": true, - "mobileHeight": 6 - }, "79056202-c92b-1dae-ce49-318ec52e2d3b": { "sizeX": 6, "sizeY": 4, @@ -13534,6 +11770,13 @@ "col": 6, "resizable": true, "mobileHeight": 6 + }, + "d0a10a8f-8f48-f9d6-8306-d12af9b49690": { + "sizeX": 12, + "sizeY": 4, + "resizable": true, + "row": 0, + "col": 0 } }, "gridSettings": { @@ -13590,14 +11833,6 @@ }, "right": { "widgets": { - "b1a9a51f-e5a6-9d5f-ef5c-25c2a68af1b0": { - "sizeX": 12, - "sizeY": 4, - "row": 0, - "col": 0, - "resizable": true, - "mobileHeight": 6 - }, "84fbe63a-bcb6-7bc1-8af0-46b3b1ee5adc": { "sizeX": 6, "sizeY": 4, @@ -13613,6 +11848,13 @@ "col": 6, "resizable": true, "mobileHeight": 6 + }, + "4544080d-9b6f-b592-9cd4-0e0335d33857": { + "sizeX": 12, + "sizeY": 4, + "resizable": true, + "row": 0, + "col": 0 } }, "gridSettings": { @@ -13827,14 +12069,6 @@ }, "right": { "widgets": { - "61a23bd5-329f-aae7-3168-8a14a51dc10b": { - "sizeX": 12, - "sizeY": 4, - "row": 0, - "col": 0, - "resizable": true, - "mobileHeight": 6 - }, "1249d3e2-6b3a-4e4a-65e9-6ed22959871e": { "sizeX": 6, "sizeY": 4, @@ -13850,6 +12084,13 @@ "col": 6, "resizable": true, "mobileHeight": 6 + }, + "5d0f2f57-499d-1324-8e1b-cfbc0b3149d2": { + "sizeX": 12, + "sizeY": 4, + "resizable": true, + "row": 0, + "col": 0 } }, "gridSettings": { diff --git a/ui-ngx/src/assets/help/en_US/alarm-rule/alarm_rule_schedule_format.md b/ui-ngx/src/assets/help/en_US/alarm-rule/alarm_rule_schedule_format.md new file mode 100644 index 0000000000..94ffabbc34 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/alarm-rule/alarm_rule_schedule_format.md @@ -0,0 +1,123 @@ +#### Active all time schedule format + +An attribute with a dynamic value for an active all-time schedule format must contain an empty JSON object or JSON in the following format: + +```javascript +{ + "type": "ANY_TIME" +} +``` + +#### Specific time schedule format + +An attribute with a dynamic value for a specific schedule format must have JSON in the following format: + +```javascript +{ + "type": "SPECIFIC_TIME", + "daysOfWeek": [ + 2, + 4 + ], + "endsOn": 0, + "startsOn": 0, + "timezone": "Europe/Kiev" +} +``` + +
      +
    • +timezone: this value is used to designate the timezone you are using. +
    • +
    • +daysOfWeek: this value is used to designate the days in numerical representation (Monday - 1, Tuesday 2, etc.) on which the schedule will be active. +
    • +
    • +startsOn: this value is used to designate the timestamp in milliseconds, from which the schedule will be active for the designated days. +
    • +
    • +endsOn: this value is used to designate the timestamp in milliseconds until which the schedule will be active for the specified days. +
    • +
    +When startsOn and endsOn equals 0 it's means that the schedule will be active the whole day. + +#### Custom time schedule format + +An attribute with a dynamic value for a custom schedule format must have JSON in the following format: + +```javascript +{ + "type": "CUSTOM" + "timezone": "Europe/Kiev", + "items": [ + { + "dayOfWeek": 1, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 2, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 3, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 4, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 5, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 6, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 7, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + } + ] +} +``` + +
      +
    • +timezone: this value is used to designate the timezone you are using. +
    • +
    • +items: the array of values representing the days on which the schedule will be active. +
    • +
    + +One array item contains such fields: +
      +
    • +dayOfWeek: this value is used to designate the specified day in numerical representation (Monday - 1, Tuesday 2, etc.) on which the schedule will be active. +
    • +
    • +enabled: this boolean value, used to designate that the specified day in the schedule will be enabled. +
    • +
    • +startsOn: this value is used to designate the timestamp in milliseconds, from which the schedule will be active for the designated day. +
    • +
    • +endsOn: this value is used to designate the timestamp in milliseconds until which the schedule will be active for the specified day. +
    • +
    +When startsOn and endsOn equals 0 it's means that the schedule will be active the whole day. diff --git a/ui-ngx/src/assets/help/en_US/calculated-field/filter_expression_fn.md b/ui-ngx/src/assets/help/en_US/calculated-field/filter_expression_fn.md new file mode 100644 index 0000000000..ce8ca22f51 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/calculated-field/filter_expression_fn.md @@ -0,0 +1,10 @@ +## Calculated Field TBEL Filter Function + +The **filter()** function is a user-defined script that enables custom calculations using [TBEL](${siteBaseUrl}/docs${docPlatformPrefix}/user-guide/tbel/) on telemetry and attribute data. +It receives arguments configured in the calculated field setup, along with an additional `ctx` object that stores `latestTs` and provides access to all arguments. + +### Function Signature + +```javascript +function calculate(ctx, arg1, arg2, ...): boolean +``` diff --git a/ui-ngx/src/assets/help/en_US/notification/alarm.md b/ui-ngx/src/assets/help/en_US/notification/alarm.md index 00ba021e1b..e0e0dc3e8c 100644 --- a/ui-ngx/src/assets/help/en_US/notification/alarm.md +++ b/ui-ngx/src/assets/help/en_US/notification/alarm.md @@ -16,11 +16,13 @@ Available template parameters: * `alarmStatus` - the alarm status; * `alarmOriginatorEntityType` - the entity type of the alarm originator, e.g. 'Device'; * `alarmOriginatorName` - the name of the alarm originator, e.g. 'Sensor T1'; +* `alarmOriginatorLabel` - the label of the alarm originator, e.g. 'Sensor T1'; * `alarmOriginatorId` - the alarm originator entity id as uuid string; * `recipientTitle` - title of the recipient (first and last name if specified, email otherwise); * `recipientEmail` - email of the recipient; * `recipientFirstName` - first name of the recipient; * `recipientLastName` - last name of the recipient; +* `details.` - any key field from the alarm's details. Fox example, if details are `{"data": "Temperature is 25"}`, use `${details.data}` to access "Temperature is 25"; Parameter names must be wrapped using `${...}`. For example: `${action}`. You may also modify the value of the parameter with one of the suffixes: diff --git a/ui-ngx/src/assets/help/en_US/notification/alarm_assignment.md b/ui-ngx/src/assets/help/en_US/notification/alarm_assignment.md index aa80b13b35..53b2ade36d 100644 --- a/ui-ngx/src/assets/help/en_US/notification/alarm_assignment.md +++ b/ui-ngx/src/assets/help/en_US/notification/alarm_assignment.md @@ -15,6 +15,7 @@ Available template parameters: * `alarmStatus` - the alarm status; * `alarmOriginatorEntityType` - the entity type of the alarm originator, e.g. 'Device'; * `alarmOriginatorName` - the name of the alarm originator, e.g. 'Sensor T1'; +* `alarmOriginatorLabel` - the label of the alarm originator, e.g. 'Sensor T1'; * `alarmOriginatorId` - the alarm originator entity id as uuid string; * `assigneeTitle` - title of the assignee; * `assigneeEmail` - email of the assignee; diff --git a/ui-ngx/src/assets/help/en_US/notification/alarm_comment.md b/ui-ngx/src/assets/help/en_US/notification/alarm_comment.md index 150e76babc..41ada1fba2 100644 --- a/ui-ngx/src/assets/help/en_US/notification/alarm_comment.md +++ b/ui-ngx/src/assets/help/en_US/notification/alarm_comment.md @@ -15,6 +15,7 @@ Available template parameters: * `alarmStatus` - the alarm status; * `alarmOriginatorEntityType` - the entity type of the alarm originator, e.g. 'Device'; * `alarmOriginatorName` - the name of the alarm originator, e.g. 'Sensor T1'; +* `alarmOriginatorLabel` - the label of the alarm originator, e.g. 'Sensor T1'; * `alarmOriginatorId` - the alarm originator entity id as uuid string; * `comment` - text of the comment; * `action` - one of: 'added', 'updated'; diff --git a/ui-ngx/src/assets/help/en_US/notification/edge_communication_failure.md b/ui-ngx/src/assets/help/en_US/notification/edge_communication_failure.md index 712f2b45e7..0df4732ab4 100644 --- a/ui-ngx/src/assets/help/en_US/notification/edge_communication_failure.md +++ b/ui-ngx/src/assets/help/en_US/notification/edge_communication_failure.md @@ -12,6 +12,9 @@ Available template parameters: * `edgeId` - the edge id as uuid string; * `edgeName` - the name of the edge; * `failureMsg` - the string representation of the failure, occurred on the Edge; +* `recipientEmail` - email of the recipient; +* `recipientFirstName` - first name of the recipient; +* `recipientLastName` - last name of the recipient; Parameter names must be wrapped using `${...}`. For example: `${edgeName}`. You may also modify the value of the parameter with one of the suffixes: diff --git a/ui-ngx/src/assets/help/en_US/notification/edge_connection.md b/ui-ngx/src/assets/help/en_US/notification/edge_connection.md index 37f0ec7573..4c97f6ccde 100644 --- a/ui-ngx/src/assets/help/en_US/notification/edge_connection.md +++ b/ui-ngx/src/assets/help/en_US/notification/edge_connection.md @@ -12,6 +12,9 @@ Available template parameters: * `edgeId` - the edge id as uuid string; * `edgeName` - the name of the edge; * `eventType` - the string representation of the connectivity status: connected or disconnected; +* `recipientEmail` - email of the recipient; +* `recipientFirstName` - first name of the recipient; +* `recipientLastName` - last name of the recipient; Parameter names must be wrapped using `${...}`. For example: `${edgeName}`. You may also modify the value of the parameter with one of the suffixes: diff --git a/ui-ngx/src/assets/help/en_US/notification/resources_shortage.md b/ui-ngx/src/assets/help/en_US/notification/resources_shortage.md index 6ea03a514c..2c0634a5f0 100644 --- a/ui-ngx/src/assets/help/en_US/notification/resources_shortage.md +++ b/ui-ngx/src/assets/help/en_US/notification/resources_shortage.md @@ -13,6 +13,9 @@ Available template parameters: * `usage` - the current usage value of the resource; * `serviceId` - the service id (convenient in cluster setup); * `serviceType` - the service type (convenient in cluster setup); +* `recipientEmail` - email of the recipient; +* `recipientFirstName` - first name of the recipient; +* `recipientLastName` - last name of the recipient; Parameter names must be wrapped using `${...}`. For example: `${resource}`. You may also modify the value of the parameter with one of the suffixes: diff --git a/ui-ngx/src/assets/help/en_US/notification/task_processing_failure.md b/ui-ngx/src/assets/help/en_US/notification/task_processing_failure.md index 0bcf107298..da46dc11bd 100644 --- a/ui-ngx/src/assets/help/en_US/notification/task_processing_failure.md +++ b/ui-ngx/src/assets/help/en_US/notification/task_processing_failure.md @@ -16,6 +16,9 @@ Available template parameters: * `entityType` - the type of the entity to which the task is related; * `entityId` - the id of the entity to which the task is related; * `attempt` - the number of attempts processing the task +* `recipientEmail` - email of the recipient; +* `recipientFirstName` - first name of the recipient; +* `recipientLastName` - last name of the recipient; Parameter names must be wrapped using `${...}`. For example: `${entityType}`. You may also modify the value of the parameter with one of the suffixes: diff --git a/ui-ngx/src/assets/help/en_US/widget/action/place_map_item/create_dialog_js.md b/ui-ngx/src/assets/help/en_US/widget/action/place_map_item/create_dialog_js.md index bc8823c777..3bb410f10b 100644 --- a/ui-ngx/src/assets/help/en_US/widget/action/place_map_item/create_dialog_js.md +++ b/ui-ngx/src/assets/help/en_US/widget/action/place_map_item/create_dialog_js.md @@ -79,7 +79,7 @@ function AddEntityDialogController(instance) { const mapType = widgetContext.mapInstance.type(); attributes.push({key: mapType === 'image' ? 'xPos' : 'latitude', value: additionalParams.coordinates.x}); attributes.push({key: mapType === 'image' ? 'yPos' : 'longitude', value: additionalParams.coordinates.y}); - } else if (mapItemType === 'Rectangle' || mapItemType === 'Polygon') { + } else if (mapItemType === 'Rectangle' || mapItemType === 'Polygon' || mapItemType === 'Line') { attributes.push({key: 'perimeter', value: additionalParams.coordinates}); } else if (mapItemType === 'Circle') { attributes.push({key: 'circle', value: additionalParams.coordinates}); diff --git a/ui-ngx/src/assets/help/en_US/widget/action/place_map_item/place_map_item_action.md b/ui-ngx/src/assets/help/en_US/widget/action/place_map_item/place_map_item_action.md index 131a674099..b192042d2f 100644 --- a/ui-ngx/src/assets/help/en_US/widget/action/place_map_item/place_map_item_action.md +++ b/ui-ngx/src/assets/help/en_US/widget/action/place_map_item/place_map_item_action.md @@ -26,8 +26,9 @@ A JavaScript function triggered after a map item is placed. Optionally uses an H
  • coordinates: Coordinates - Represents geographical coordinates of the placed map item. The actual format of this parameter depends on the type of the selected map item:
    • Marker: {x: number; y: number}, where x represents latitude, and y represents longitude.
    • -
    • Polygon, Rectangle: TbPolygonRawCoordinates contains an array of points defining the shape boundaries.
    • -
    • Circle: TbCircleData contains center coordinates and radius information.
    • +
    • Polygon, Rectangle: TbPolygonRawCoordinates contains an array of points defining the shape boundaries.
    • +
    • Circle: TbCircleData contains center coordinates and radius information.
    • +
    • Polyline: TbPolylineRawCoordinates contains an array of points defining the polyline.
    Note: The coordinates will be automatically converted according to the selected map type.
  • diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_color_fn.md index be95f136ad..9af015d033 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_color_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_color_fn.md @@ -5,7 +5,7 @@ *function (data, dsData): string* -A JavaScript function used to compute color of the trip path point. +A JavaScript function used to compute color of the path point. **Parameters:** @@ -15,7 +15,7 @@ A JavaScript function used to compute color of the trip path point. **Returns:** -Should return string value presenting color of the trip path point. +Should return string value presenting color of the path point. In case no data is returned, color value from **Color** settings field will be used. diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_tooltip_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_tooltip_fn.md index 4ce996626a..e9e202ef85 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_tooltip_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_tooltip_fn.md @@ -5,7 +5,7 @@ *function (data, dsData): string* -A JavaScript function used to compute text or HTML code to be displayed in the trip path point tooltip. +A JavaScript function used to compute text or HTML code to be displayed in the path point tooltip. **Parameters:** diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/polyline_label_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/polyline_label_fn.md new file mode 100644 index 0000000000..b2eafef8cb --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/polyline_label_fn.md @@ -0,0 +1,22 @@ +#### Polyline label function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute text or HTML code of the polyline label. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML of the polyline label. + +
    + +{% include widget/lib/map/label_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/polyline_stroke_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/polyline_stroke_color_fn.md new file mode 100644 index 0000000000..6b49763393 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/polyline_stroke_color_fn.md @@ -0,0 +1,24 @@ +#### Polyline stroke color function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute stroke color of the polyline. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting stroke color of the polyline. + +In case no data is returned, color value from **Color** settings field will be used. + +
    + +{% include widget/lib/map/color_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/polyline_tooltip_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/polyline_tooltip_fn.md new file mode 100644 index 0000000000..0de9b8ee7a --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/polyline_tooltip_fn.md @@ -0,0 +1,22 @@ +#### Polyline tooltip function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute text or HTML code to be displayed in the polyline tooltip. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML for the polyline tooltip. + +
    + +{% include widget/lib/map/tooltip_fn_examples %} 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 dbacae6ad3..a542ca33b6 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -496,24 +496,26 @@ "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": "Retry verification code period", "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": "Total allowed time for verification", "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": "Verification code lifetime", "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": "Within time", "within-time-pattern": "Time must be a positive integer.", - "within-time-required": "Time is required." + "within-time-required": "Time is required.", + "force-2fa": "Force two-factor authentication", + "enforce-for": "Enforce for" }, "jwt": { "security-settings": "JWT security settings", @@ -1053,7 +1055,10 @@ "type": { "simple": "Simple", "script": "Script", - "geofencing" : "Geofencing" + "geofencing" : "Geofencing", + "propagation": "Propagation", + "related-entities-aggregation": "Related entities aggregation", + "related-entities-aggregation-hint": "Aggregation of data from related entities" }, "arguments": "Arguments", "decimals-by-default": "Decimals by default", @@ -1063,6 +1068,7 @@ "datasource": "Datasource", "add-argument": "Add argument", "test-script-function": "Test script function", + "test-expression-function": "Test expression function", "no-arguments": "No arguments configured", "argument-settings": "Argument settings", "argument-current": "Current entity", @@ -1071,6 +1077,7 @@ "argument-asset": "Asset", "argument-customer": "Customer", "argument-tenant": "Current tenant", + "argument-owner": "Current owner", "argument-relation-query": "Related entities", "argument-type": "Argument type", "see-debug-events": "See debug events", @@ -1086,6 +1093,7 @@ "shared-attributes": "Shared attributes", "attribute-key": "Attribute key", "default-value": "Default value", + "default-value-required": "Default value is required.", "limit": "Max values", "time-window": "Time window", "customer-name": "Customer name", @@ -1122,19 +1130,74 @@ "report-presence-status-only": "Presence status only", "report-transition-event-and-presence": "Presence status and transition events", "perimeter-attribute-key": "Perimeter attribute key", - "relation-query": "Relations query", - "direction": "Direction", - "direction-from": "From source entity", - "direction-to": "To source entity", + "perimeter-attribute-key-required": "Perimeter attribute key is required.", + "perimeter-attribute-key-pattern": "Perimeter attribute key is invalid.", + "entity-zone-relationship": "Path from Entity to Zones *", + "direction": "Relation direction", + "direction-from": "From entity to zone", + "direction-to": "From zone to entity", "relation-type": "Relation type", - "create-relation-with-matched-zones": "Create relations with matched zones", + "create-relation-with-matched-zones": "Create relations for source entity with matched zones", "relation-level": "Relation level", "fetch-last-available-level": "Fetch last available level only", "zone-group-refresh-interval": "Zone groups refresh interval", "copy-zone-group-name": "Copy zone group name", "open-details-page": "Open entity details page", + "level": "Level", + "direction-level": "Direction", + "direction-up": "Up", + "direction-up-parent": "Up to parent", + "direction-down": "Down", + "direction-down-child": "Down to child", + "add-level": "Add level", + "delete-level": "Delete level", + "no-level": "No level configured", + "levels-required": "At least one level must be configured.", + "max-allowed-levels-error": "Relation level exceeds the maximum allowed.", + "propagation-path-related-entities": "Propagation path to related entities", + "propagate-type": { + "arguments-only": "Arguments only", + "expression-result": "Expression result" + }, + "data-propagate": "Data to propagate", + "output-key": "Output key", + "copy-output-key": "Copy output key", + "aggregation-path-related-entities": "Aggregation path to related entities", + "deduplication-interval": "Deduplication interval", + "deduplication-interval-min": "Deduplication interval should be at least {{ sec }} second.", + "deduplication-interval-required": "Deduplication interval is required.", + "metrics": { + "metrics": "Metrics", + "metrics-empty": "At least one metric must be configured.", + "metric-name": "Metric name", + "copy-metric-name": "Copy metric name", + "aggregation": "Aggregation", + "aggregation-type": { + "avg": "Average", + "min": "Minimum", + "max": "Maximum", + "sum": "Sum", + "count": "Count", + "count-unique": "Count unique" + }, + "filtered": "Filtered", + "value-source": "Value source", + "value-source-type": { + "key": "Key", + "function": "Function" + }, + "no-metrics-configured": "No metrics configured", + "add-metric": "Add metric", + "max-metrics": "Maximum number of metrics reached.", + "metric-settings": "Metric settings", + "filter": "Filter", + "filter-hint": "Enables filtering of entities during aggregation. The filter function must return a boolean value and can use all configured arguments." + }, "hint": { "arguments-simple-with-rolling": "Simple type calculated field should not contain keys with time series rolling type.", + "arguments-propagate-arguments-with-rolling": "'Time series rolling' type is incompatible with 'Arguments only' propagation.", + "arguments-propagate-argument-entity-type": "Entity type is incompatible with 'Arguments only' propagation.", + "arguments-propagate-argument-must-current-entity": "At least one argument must be configured with the 'Current entity' source entity type.", "arguments-empty": "Arguments should not be empty.", "expression-required": "Expression is required.", "expression-invalid": "Expression is invalid", @@ -1144,7 +1207,13 @@ "argument-name-duplicate": "Argument with such name already exists.", "argument-name-max-length": "Argument name should be less than 256 characters.", "argument-name-forbidden": "Argument name is reserved and cannot be used.", - "name-required": "Mame is required.", + "output-key-required": "Output key is required.", + "output-key-pattern": "Output key is invalid.", + "output-key-duplicate": "Key with such name already exists.", + "output-key-max-length": "Output key should be less than 256 characters.", + "output-key-forbidden": "Output key is reserved and cannot be used.", + "entity-type-required": "Entity type is required", + "name-required": "Name is required.", "name-pattern": "Name is invalid.", "name-duplicate": "Name with such name already exists.", "name-max-length": "Name should be less than 256 characters.", @@ -1158,7 +1227,7 @@ "entity-coordinates": "Specify the time series keys that provide entity GPS coordinates (latitude and longitude).", "geofencing-zone-groups": "Define one or more geofencing zones groups to check (e.g. 'allowedZones', 'restrictedZones'). Each group must have a unique name, which is used as a prefix for calculated field output telemetry keys.", "perimeter-attribute-key": "Set the attribute key that contains the geofencing zone perimeter definition. The perimeter is always taken from server-side attributes of the zone entity.", - "report-strategy": "Presence status reports whether the entity is currently INSIDE or OUTSIDE the zone group.Transition events report when the entity ENTERED or LEFT the zone group.", + "report-strategy": "Presence status reports whether the entity is currently INSIDE or OUTSIDE the zone group. Transition events report when the entity ENTERED or LEFT the zone group.", "create-relation-with-matched-zones": "Automatically create and maintain relations between the entity and the zones it is currently inside. Relations are removed when the entity leaves a zone and created when it enters a new one.", "relation-type-required": "Relation type is required.", "relation-level-required": "Relation level is required.", @@ -1167,11 +1236,149 @@ "geofencing-empty": "At least one zone group must be configured.", "geofencing-entity-not-found": "Geofencing target entity not found.", "max-geofencing-zone": "Maximum number of geofencing zones reached.", - "zone-group-refresh-interval": "Defines how often zone groups configured via related entities are refreshed. Set to 0 to disable scheduled refresh.", + "zone-group-refresh-interval": "Defines how often zone groups configured via related entities are refreshed.", "zone-group-refresh-interval-required": "Zone groups refresh interval is required.", - "zone-group-refresh-interval-min": "Zone group refresh interval is below the minimum allowed system interval." + "zone-group-refresh-interval-min": "Zone group refresh interval should be at least {{ min }} second.", + "propagation-path-related-entities": "Defines a direct, single-level path to a related entity based on the selected direction and relation type.", + "data-propagate": "Defines the data to be propagated from the arguments configured below. 'Arguments only' uses the retrieved data directly, while 'Expression result' calculates a new value from that data.", + "aggregation-path-related-entities": "Defines a single-level aggregation path via direct relations with parent or child entities based on direction and relation type. Only relations between device, asset, customer, and tenant entities are supported.", + "arguments-aggregation": "Defines input parameters used for filtering and aggregation.", + "setting-arguments-aggregation": "Data will be fetched from related entities configured in aggregation path.", + "metrics": "Defines metrics aggregated based on the configured arguments." } }, + "alarm-rule": { + "alarm-rules-tab": "Alarm rules", + "alarm-rule": "Alarm rule", + "alarm-rules": "Alarm rules", + "alarm-rules-old": "Old", + "alarm-rules-actual": "Actual", + "severities": "Severities", + "cleared": "Clear condition", + "delete-title": "Are you sure you want to delete the alarm rule '{{title}}'?", + "delete-text": "Be careful, after the confirmation the alarm rule and all related data will become unrecoverable.", + "delete-multiple-title": "Are you sure you want to delete { count, plural, =1 {1 alarm rule} other {# alarm rules} }?", + "delete-multiple-text": "Be careful, after the confirmation all selected alarm rules will be removed and all related data will become unrecoverable.", + "create": "Create new alarm rule", + "no-found": "No alarm rules found", + "list": "{ count, plural, =1 {One alarm rule} other {List of # alarm rules} }", + "selected-fields": "{ count, plural, =1 {1 alarm rule} other {# alarm rules} } selected", + "import": "Import alarm rule", + "export": "Export alarm rule", + "export-failed-error": "Unable to export alarm rule: {{error}}", + "alarm-type": "Alarm type", + "alarm-type-required": "Alarm type is required.", + "alarm-type-pattern": "Alarm type is invalid.", + "alarm-type-max-length": "Alarm type should be less than 256 characters.", + "clear-alarm": "Clear alarm", + "value-argument": "Argument", + "value-argument-required": "Argument is required.", + "static-settings": "Static settings", + "configuration": "Configuration", + "static-schedule": "Static", + "dynamic-schedule": "Dynamic", + "operation-and": "AND", + "operation-or": "OR", + "condition-during": "During {{during}}", + "condition-during-dynamic": "During \"{{ attribute }}\"", + "condition-repeat-times": "Repeats { count, plural, =1 {1 time} other {# times} }", + "condition-repeat-times-dynamic": "Repeats \"{ attribute }\"", + "filter-preview": "Filter preview", + "condition-settings": "Condition settings", + "static": "Static", + "dynamic": "Dynamic", + "argument-filters": "Argument filters", + "argument-name": "Argument name", + "value-type": "Value type", + "general": "General", + "filter": "Filter", + "operation": "Operation", + "value-source": "Value source", + "value": "Value", + "ignore-case": "Ignore case", + "condition": "Condition", + "script": "Script", + "add-filter": "Add filter", + "edit-filter": "Edit filter", + "conditions": { + "simple": "Simple", + "duration": "Duration", + "repeating": "Repeating" + }, + "schedule-title": "Schedule", + "edit-schedule": "Edit alarm schedule", + "schedule-type": "Scheduler type", + "schedule-type-required": "Scheduler type is required.", + "schedule": { + "any-time": "Active all the time", + "specific-time": "Active at a specific time", + "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.", + "expression-type": { + "simple": "Simple", + "tbel": "TBEL" + }, + "operation-type": { + "and": "And", + "or": "Or" + }, + "filter-predicate-type": { + "string": "String", + "numeric": "Numeric", + "boolean": "Boolean", + "complex": "Complex" + }, + "alarm-rule-additional-info": "Additional info", + "edit-alarm-rule-additional-info": "Edit additional info", + "alarm-rule-additional-info-placeholder": "Please provide your comments and adjustments here to display them within Alarm details under Additional info", + "alarm-rule-additional-info-hint": "Hint: use ${Argument name} to substitute values of the arguments that are used in alarm rule condition.", + "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", + "alarm-rule-condition": "Alarm rule condition", + "enter-alarm-rule-condition-prompt": "Please add alarm rule condition", + "edit-alarm-rule-condition": "Edit alarm rule condition", + "condition-type": "Condition type", + "select-alarm-severity": "Select alarm severity", + "add-create-alarm-rule-prompt": "Please add create alarm rule", + "add-create-alarm-rule": "Add create condition", + "add-clear-alarm-rule": "Add clear condition", + "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.", + "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.", + "create-alarm-rules": "Create alarm rules", + "clear-alarm-rule": "Clear alarm rule", + "no-clear-alarm-rule": "No clear condition configured", + "advanced-settings": "Advanced settings", + "propagate-alarm": "Propagate alarm to related entities", + "alarm-rule-relation-types-list": "Relation types", + "alarm-rule-relation-types-list-hint": "Defines relation types to filter the related entities. If not set, the alarm will be propagated to all related entities.", + "propagate-alarm-to-owner": "Propagate alarm to entity owner (Customer or Tenant)", + "propagate-alarm-to-tenant": "Propagate alarm to Tenant", + "debugging": "Alarm rule debugging" + }, "ai-models": { "ai-models": "AI models", "ai-model": "AI model", @@ -1194,13 +1401,15 @@ "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", - "github-models": "GitHub Models" + "github-models": "GitHub Models", + "ollama": "Ollama" }, "name-required": "Name is required.", "name-max-length": "Name must be 255 characters or less.", "provider": "Provider", "api-key": "API key", "api-key-required": "API key is required.", + "api-key-open-ai-required": "API key is required when using the official OpenAI API.", "project-id": "Project ID", "project-id-required": "Project ID is required", "location": "Location", @@ -1237,17 +1446,34 @@ "frequency-penalty": "Frequency penalty", "frequency-penalty-hint": "Applies a penalty to a token's likelihood that increases based on its frequency in the text.", "max-output-tokens": "Maximum output tokens", - "max-output-tokens-min": "Must be greater than 0.", "max-output-tokens-hint": "Sets the maximum number of tokens that the \nmodel can generate in a single response.", + "context-length": "Context length", + "context-length-hint": "Defines the size of the context window in tokens. This value sets the total memory limit for the model, including both the user's input and the generated response.", "endpoint": "Endpoint", "endpoint-required": "Endpoint is required.", + "baseurl": "Base URL", + "baseurl-required": "Base URL is required.", "service-version": "Service version", "check-connectivity": "Check connectivity", "check-connectivity-success": "Test request was successful", "check-connectivity-failed": "Test request failed", "no-model-matching": "No models matching '{{entity}}' were found.", "model-required": "Model is required.", - "no-model-text": "No models found." + "no-model-text": "No models found.", + "authentication": "Authentication", + "authentication-basic-hint": "Uses standard HTTP Basic authentication. The username and password will be combined, Base64-encoded, and sent in an \"Authorization\" header with each request to the Ollama server.", + "authentication-token-hint": "Uses Bearer token authentication. The provided token will be sent directly in an \"Authorization\" eader with each request to the Ollama server.", + "authentication-type": { + "none": "None", + "basic": "Basic", + "token": "Token" + }, + "username": "Username", + "username-required": "Username is required.", + "password": "Password", + "password-required": "Password is required.", + "token": "Token", + "token-required": "Token is required." }, "confirm-on-exit": { "message": "You have unsaved changes. Are you sure you want to leave this page?", @@ -1429,6 +1655,8 @@ "mobile-order": "Dashboard order in mobile application", "mobile-hide": "Hide dashboard in mobile application", "update-image": "Update dashboard image", + "update-new-version": "Upload new version", + "upload-file-to-update": "Upload file to update", "take-screenshot": "Take screenshot", "select-widget-title": "Select widget", "select-widget-value": "{{title}}: select widget", @@ -1799,6 +2027,8 @@ "bootstrap-tab": "Bootstrap Client", "bootstrap-server": "Bootstrap Server", "lwm2m-server": "LwM2M Server", + "client-reboot": "Registration Update Trigger", + "bootstrap-reboot": "Bootstrap-Request Trigger", "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.", @@ -2300,6 +2530,7 @@ "short-id-required": "Short server ID is required.", "short-id-range": "Short server ID should be in a range from {{ min }} to {{ max }}.", "short-id-pattern": "Short server ID must be a positive integer.", + "short-id-pattern-bs": "Short server ID must be only null", "lifetime": "Client registration lifetime", "lifetime-required": "Client registration lifetime is required.", "lifetime-pattern": "Client registration lifetime must be a positive integer.", @@ -2394,7 +2625,9 @@ "composite-all-description": "All resources are observed with a single Composite Observe request (more efficient, less flexible)", "composite-by-object": "Composite by objects", "composite-by-object-description": "Resources are grouped by object type and observed using separate Composite Observe requests (balanced approach)" - } + }, + "init-attr-tel-as-obs-strategy": "Initialize attributes and telemetry using Observe strategy", + "init-attr-tel-as-obs-strategy-hint": "If false - attributes and telemetry are initialized by reading their values one by one.\\nIf true - attributes and telemetry are initialized by subscribing to their values using the Observe strategy." }, "snmp": { "add-communication-config": "Add communication config", @@ -2731,6 +2964,7 @@ "details": "Entity details", "no-entities-prompt": "No entities found", "no-data": "No data to display", + "show-all-columns":"Show All", "columns-to-display": "Columns to Display", "type-api-usage-state": "API Usage State", "type-edge": "Edge", @@ -2894,7 +3128,7 @@ "target-entity": "Target entity", "attributes-propagation": "Attributes propagation", "attributes-propagation-hint": "Entity View will automatically copy specified attributes from Target Entity each time you save or update this entity view. For performance reasons target entity attributes are not propagated to entity view on each attribute change. You can enable automatic propagation by configuring \"copy to view\" rule node in your rule chain and linking \"Post attributes\" and \"Attributes Updated\" messages to the new rule node.", - "timeseries-data": "Times eries data", + "timeseries-data": "Time series data", "timeseries-data-hint": "Configure time series data keys of the target entity that will be accessible to the entity view. This time series data is read-only.", "search": "Search entity views", "selected-entity-views": "{ count, plural, =1 {1 entity view} other {# entity views} } selected", @@ -3884,7 +4118,49 @@ "activation-link-expired": "Activation link has expired", "activation-link-expired-message": "The link to activate your profile has expired. You can return to the login page to receive a new email.", "reset-password-link-expired": "Password reset link has expired", - "reset-password-link-expired-message": "The link to reset your password has expired. You can return to the login page to receive a new email." + "reset-password-link-expired-message": "The link to reset your password has expired. You can return to the login page to receive a new email.", + "two-fa": "Two-factor authentication", + "two-fa-required": "Two-factor authentication is required", + "set-up-verification-method": "Set up a verification method to continue", + "set-up-verification-method-login": "Set up a verification method or login", + "enable-authenticator-app": "Enable authenticator app", + "enable-authenticator-app-description": "Please enter the security code from your authenticator app", + "enable-authenticator-sms": "Enable SMS authenticator", + "enable-authenticator-sms-description": "Enter a 6-digit code we just sent to ", + "enable-authenticator-email": "Enable email authenticator", + "enable-authenticator-email-description": "A security code has been sent to your email address at ", + "enter-key-manually": "or enter this 32-digits key manually:", + "continue": "Continue", + "confirm": "Confirm", + "authenticator-app-success": "Authenticator app successfully enabled", + "authenticator-app-success-description": "The next time you log in, you will need to provide a two-factor authentication code", + "authenticator-sms-success": "SMS authenticator successfully enabled", + "authenticator-sms-success-description": "The next time you log in, you will be prompted to enter the security code that will be sent to the phone number", + "authenticator-email-success": "Email authenticator successfully enabled", + "authenticator-email-success-description": "The next time you log in, you will be prompted to enter the security code that will be sent to your email address", + "authenticator-backup-code-success": "Backup code successfully enabled", + "authenticator-backup-code-success-description": "The next time you log in, you will be prompted to enter the security code or use one of backup code.", + "add-verification-method": "Add verification method", + "get-backup-code": "Get backup code", + "copy-key": "Copy key", + "send-code": "Send code", + "email-label": "Email", + "email-description": "Enter an email to use as your authenticator.", + "sms-description": "Enter a phone number to use as your authenticator.", + "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)", + "print": "Print", + "verification-code": "6-digit code", + "verification-code-invalid": "Invalid verification code format", + "scan-qr-code": "Scan this QR code with your verification app", + "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": "Phone Number in E.164 format, ex. {{phoneNumber}}" + } }, "markdown": { "edit": "Edit", @@ -4570,7 +4846,8 @@ "jks": "JKS", "js-module": "JS module", "lwm2m-model": "LWM2M model", - "pkcs-12": "PKCS #12" + "pkcs-12": "PKCS #12", + "general": "General" }, "resource-sub-type": "Sub-type", "sub-type": { @@ -4578,7 +4855,12 @@ "scada-symbol": "Scada symbol", "extension": "Extension", "module": "Module" - } + }, + "resource-is-in-use": "Resource is used by other entities", + "resources-are-in-use": "Resources are used by other entities", + "resource-is-in-use-text": "The Resource '{{title}}' was not deleted because it is used by the following entities:", + "resources-are-in-use-text": "Not all Resources have been deleted because they are used by other entities.
    You can view referenced entities by clicking the References button in the corresponding resource row.
    If you still want to delete these resources, select them in the table below and click the Delete selected button.", + "delete-resource-in-use-text": "If you still want to delete the resource, click the Delete anyway button." }, "javascript": { "add": "Add JavaScript resource", @@ -5478,7 +5760,7 @@ "time-series": "Time series", "latest": "Latest values", "web-sockets": "WebSockets", - "calculated-fields": "Calculated fields" + "calculated-fields-and-alarm-rules": "Calculated fields and alarm rules" }, "save-attribute": { "processing-settings": "Processing settings", @@ -5549,7 +5831,8 @@ "timeout-required": "Timeout is required", "timeout-validation": "Must be from 1 second to 10 minutes.", "force-acknowledgement": "Force acknowledgement", - "force-acknowledgement-hint": "If enabled, the incoming message is acknowledged immediately. The model's response is then enqueued as a separate, new message." + "force-acknowledgement-hint": "If enabled, the incoming message is acknowledged immediately. The model's response is then enqueued as a separate, new message.", + "ai-resources": "AI resources" } }, "timezone": { @@ -5815,6 +6098,9 @@ "max-related-level-per-argument-required": "Relation level per 'Related entities' argument max number is required", "min-allowed-scheduled-update-interval": "Min allowed update interval for 'Related entities' arguments (seconds)", "min-allowed-scheduled-update-interval-range": "Min allowed update interval min number can't be negative", + "min-allowed-deduplication-interval": "Min allowed deduplication interval (seconds)", + "min-allowed-deduplication-interval-range": "Min allowed deduplication interval value can't be negative", + "min-allowed-deduplication-interval-required": "Min allowed deduplication interval is required", "min-allowed-scheduled-update-interval-required": "Min allowed update interval min number is required", "max-state-size": "State maximum size in KB", "max-state-size-range": "State maximum size in KB can't be negative", @@ -5891,6 +6177,10 @@ "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", + "relation-search-entity-limit": "Relation search entity limit", + "relation-search-entity-limit-hint": "Limits the number of entities resolved at the last level of the relation path. Applies to 'Related entities' arguments and Propagation fields.", + "relation-search-entity-limit-required": "Relation search entity limit", + "relation-search-entity-limit-range": "Relation search entity limit can't be less than '1'", "rate-limits": { "add-limit": "Add limit", "and-also-less-than": "and also less than", @@ -6088,6 +6378,7 @@ "show-date-time-interval": "Show date time interval", "show-date-time-interval-hint": "Show date time interval according to the data aggregation.", "hide-zero-tooltip-values": "Hide zero values", + "show-stack-total": "Show total value in stack mode", "background-color": "Background color", "background-blur": "Background blur" }, @@ -6981,7 +7272,8 @@ "marker": "Marker", "polygon": "Polygon", "rectangle": "Rectangle", - "circle": "Circle" + "circle": "Circle", + "polyline": "Polyline" }, "place-map-item": "Place map item", "map-item-tooltip": { @@ -6993,7 +7285,9 @@ "continue-draw-polygon": "Continue draw polygon", "finish-draw-polygon": "Finish draw polygon", "start-draw-circle": "Start draw circle", - "finish-draw-circle": "Finish draw circle" + "finish-draw-circle": "Finish draw circle", + "start-draw-polyline": "Start draw polyline", + "finish-draw-polyline": "Finish draw polyline" } }, "widgets-bundle": { @@ -8009,14 +8303,14 @@ "attribute-scope-server": "Server attribute", "attribute-scope-shared": "Shared attribute", "value-required": "Value required", - "image-settings": "Image settings", + "image-settings": "Image output 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", + "image-quality": "Image quality", + "max-image-width": "Max width", + "max-image-height": "Max height", "action-buttons": "Action buttons", "show-action-buttons": "Show action buttons", "update-all-values": "Update all values, not only modified", @@ -8097,7 +8391,10 @@ "add-radio-option": "Add radio option", "radio-label-position": "Label position", "radio-label-position-before": "Before", - "radio-label-position-after": "After" + "radio-label-position-after": "After", + "save-image": "Save image", + "save-to-gallery": "Automatically store captured images in Image Gallery", + "public-image": "Makes image avaliable for any unauthorized user" }, "invalid-qr-code-text": "Invalid input text for QR code. Input should have a string type", "qr-code": { @@ -8392,7 +8689,8 @@ "trips": "Trips", "markers": "Markers", "polygons": "Polygons", - "circles": "Circles" + "circles": "Circles", + "polylines": "Polylines" }, "data-layer": { "source": "Source", @@ -8589,6 +8887,25 @@ "finish-circle-hint-with-entity": "Circle for '{{entityName}}': click to finish and save circle", "finish-circle-hint": "Circle: click to finish drawing" }, + "polyline": { + "polyline-key": "Polyline key", + "polyline-key-required": "Polyline key required", + "no-polylines": "No polylines configured", + "add-polylines": "Add polyline", + "polyline-configuration": "Polyline configuration", + "remove-polyline": "Remove polyline", + "edit": "Edit polyline", + "cut": "Cut polyline area", + "rotate": "Rotate polyline", + "remove-polyline-for": "Remove polyline for '{{entityName}}'", + "draw-polyline": "Draw polyline", + "polyline-place-first-point-hint-with-entity": "Polyline for '{{entityName}}': click to place first point", + "polyline-place-first-point-hint": "Polyline: click to place first point", + "finish-polyline-hint-with-entity": "Polyline for '{{entityName}}': click to finish drawing", + "finish-polyline-hint": "Polyline: click to finish drawing", + "polyline-place-first-point-cut-hint": "Click to place first point", + "finish-polyline-cut-hint": "Click first marker to finish and save" + }, "select-entity": "Select entity", "select-entity-hint": "Hint: after selection click at the map to set position" }, @@ -9030,6 +9347,7 @@ "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": "Timestamp", + "timestamp-column-name":"Timestamp", "display-pagination": "Display pagination", "default-page-size": "Default page size", "page-step-settings": "Page step settings", @@ -9091,7 +9409,11 @@ "alarm-column-error": "At least one alarm column should be specified", "table-tabs": "Table tabs", "show-cell-actions-menu-mobile": "Show cell actions dropdown menu in mobile mode", - "disable-sorting": "Disable sorting" + "disable-sorting": "Disable sorting", + "sort-by": "Sort tabs by", + "sort-asc": "Name Ascending", + "sort-desc": "Name Descending", + "sort-timestamp-option": "Created time" }, "latest-chart": { "total": "Total", @@ -9601,7 +9923,8 @@ "add-key": "Add key", "no-key": "No key", "delete-key": "Delete key", - "target-dashboard-state": "Target dashboard state" + "target-dashboard-state": "Target dashboard state", + "go-to-main-state": "Go to default view" } }, "color": { @@ -9651,6 +9974,7 @@ "items-per-page-separator": "of" }, "language": { + "auto": "Auto", "language": "Language", "locales": { "ar_AE": "العربية (الإمارات العربية المتحدة)", diff --git a/ui-ngx/src/assets/locale/locale.constant-lt_LT.json b/ui-ngx/src/assets/locale/locale.constant-lt_LT.json index 935ae65eb4..c3ddb5af74 100644 --- a/ui-ngx/src/assets/locale/locale.constant-lt_LT.json +++ b/ui-ngx/src/assets/locale/locale.constant-lt_LT.json @@ -1,7708 +1,9524 @@ -{ - "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-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-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ę" - } +{ + "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": "Paskyra", + "notification-settings": "Pranešimų nustatymai" + }, + "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ą", + "apply": "Taikyti", + "apply-changes": "Pritaikyti pakeitimus", + "edit-mode": "Redagavimo režimas", + "enter-edit-mode": "Redagavimo režimas", + "decline-changes": "Atšaukti pakeitimus", + "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", + "test": "Test", + "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", + "upload": "Įkelti", + "delete-anyway": "Trinti vistike", + "delete-selected": "Trinti pasirinktą", + "set": "Nustatyti" + }, + "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": "Nustatymai", + "general": "Bendrieji", + "general-settings": "Bendrieji nustatymai", + "home-settings": "Pagrindinio puslapio nustatymai", + "home": "Pagrindinis puslapis", + "outgoing-mail": "Pašto serveris", + "outgoing-mail-settings": "Išeinančio pašto serverio nustatymai", + "system-settings": "Sistemos nustatymai", + "test-mail-sent": "Bandomasis laiškas sėkmingai išsiųstas!", + "base-url": "Pagrindinis URL", + "base-url-required": "Pagrindinis URL yra privalomas.", + "prohibit-different-url": "Drausti naudoti kliento užklausų antraštėse nurodytą pagrindinio kompiuterio pavadinimą", + "prohibit-different-url-hint": "Šis nustatymas turėtų būti įjungtas gamybinėje aplinkoje. Jo išjungimas gali sukelti saugumo problemų", + "device-connectivity": { + "device-connectivity": "Įrenginių jungiamumas", + "http-s": "HTTP(s)", + "mqtt-s": "MQTT(s)", + "coap-s": "COAP(s)", + "http": "HTTP", + "https": "HTTPs", + "mqtt": "MQTT", + "mqtts": "MQTTs", + "coap": "COAP", + "coaps": "COAPs", + "hint": "Jei laukeliai 'host' arba 'port' yra tušti, bus naudojamos numatytosios protokolo reikšmės.", + "host": "Host", + "port": "Port", + "port-pattern": "Port turi būti teigiamas sveikasis skaičius.", + "port-range": "Port turi būti nuo 1 iki 65535." }, - "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", + "mail-from": "Laiškas nuo", + "mail-from-required": "Laukas „Laiškas nuo“ yra privalomas.", + "smtp-protocol": "SMTP protokolas", + "smtp-host": "SMTP serveris", + "smtp-host-required": "SMTP serveris yra privalomas.", + "smtp-port": "SMTP prievadas", + "smtp-port-required": "Turite nurodyti SMTP prievadą.", + "smtp-port-invalid": "Tai neatrodo kaip tinkamas SMTP prievadas.", + "timeout-msec": "Laiko limitas (msek)", + "timeout-required": "Laiko limitas yra privalomas.", + "timeout-invalid": "Tai neatrodo kaip tinkamas laiko limitas.", + "enable-tls": "Įjungti TLS", + "tls-version": "TLS versija", + "enable-proxy": "Įjungti proxy", + "proxy-host": "Proxy serveris", + "proxy-host-required": "Proxy serveris yra privalomas.", + "proxy-port": "Proxy prievadas", + "proxy-port-required": "Proxy prievadas yra privalomas.", + "proxy-port-range": "Proxy prievadas turi būti nuo 1 iki 65535.", + "proxy-user": "Proxy naudotojas", + "proxy-password": "Proxy slaptažodis", + "change-password": "Pakeisti slaptažodį", + "send-test-mail": "Siųsti bandomąjį laišką", + "sms-provider": "SMS tiekėjas", + "sms-provider-settings": "SMS tiekėjo nustatymai", + "sms-provider-type": "SMS tiekėjo tipas", + "sms-provider-type-required": "SMS tiekėjo tipas yra privalomas.", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "sms-provider-type-smpp": "SMPP", + "aws-access-key-id": "AWS prieigos raktas (Access Key ID)", + "aws-access-key-id-required": "AWS prieigos raktas (Access Key ID) yra privalomas", + "aws-secret-access-key": "AWS slaptas prieigos raktas (Secret Access Key)", + "aws-secret-access-key-required": "AWS slaptas prieigos raktas (Secret Access Key) yra privalomas", + "aws-region": "AWS regionas", + "aws-region-required": "AWS regionas yra privalomas", + "number-from": "Telefono numeris nuo", + "number-from-required": "Telefono numeris nuo yra privalomas.", + "number-to": "Telefono numeris kam", + "number-to-required": "Telefono numeris kam yra privalomas.", + "phone-number-hint": "Telefono numeris E.164 formatu, pvz. +19995550123", + "phone-number-hint-twilio": "Telefono numeris E.164 formatu / Telefono numerio SID / Žinučių paslaugos SID, pvz. +19995550123/PNXXX/MGXXX", + "phone-number-pattern": "Neteisingas telefono numeris. Turi būti E.164 formatu, pvz. +19995550123.", + "phone-number-pattern-twilio": "Neteisingas telefono numeris. Turi būti E.164 formatu / Telefono numerio SID / Žinučių paslaugos SID, pvz. +19995550123/PNXXX/MGXXX.", + "sms-message": "SMS žinutė", + "sms-message-required": "SMS žinutė yra privaloma.", + "sms-message-max-length": "SMS žinutė negali būti ilgesnė nei 1600 simbolių", + "twilio-account-sid": "Twilio paskyros SID", + "twilio-account-sid-required": "Twilio paskyros SID yra privalomas", + "twilio-account-token": "Twilio paskyros prieigos raktas (Token)", + "twilio-account-token-required": "Twilio paskyros prieigos raktas (Token) yra privalomas", + "send-test-sms": "Siųsti bandomąją SMS", + "test-sms-sent": "Bandomoji SMS sėkmingai išsiųsta!", + "security-settings": "Saugumo nustatymai", + "password-policy": "Slaptažodžių politika", + "minimum-password-length": "Minimalus slaptažodžio ilgis", + "minimum-password-length-required": "Minimalus slaptažodžio ilgis yra privalomas", + "minimum-password-length-range": "Minimalus slaptažodžio ilgis turi būti nuo 5 iki 50 simbolių", + "maximum-password-length": "Maksimalus slaptažodžio ilgis", + "maximum-password-length-min": "Maksimalus slaptažodžio ilgis turi būti bent 6 simboliai", + "maximum-password-length-less-min": "Maksimalus slaptažodžio ilgis turi būti didesnis nei minimalus", + "minimum-uppercase-letters": "Minimalus didžiųjų raidžių kiekis", + "minimum-uppercase-letters-range": "Minimalus didžiųjų raidžių kiekis negali būti neigiamas", + "minimum-lowercase-letters": "Minimalus mažųjų raidžių kiekis", + "minimum-lowercase-letters-range": "Minimalus mažųjų raidžių kiekis negali būti neigiamas", + "minimum-digits": "Minimalus skaitmenų kiekis", + "minimum-digits-range": "Minimalus skaitmenų kiekis negali būti neigiamas", + "minimum-special-characters": "Minimalus specialiųjų simbolių kiekis", + "minimum-special-characters-range": "Minimalus specialiųjų simbolių kiekis negali būti neigiamas", + "password-expiration-period-days": "Slaptažodžio galiojimo laikotarpis dienomis", + "password-expiration-period-days-range": "Slaptažodžio galiojimo laikotarpis dienomis negali būti neigiamas", + "password-reuse-frequency-days": "Slaptažodžio pakartotinio naudojimo dažnis dienomis", + "password-reuse-frequency-days-range": "Slaptažodžio pakartotinio naudojimo dažnis dienomis negali būti neigiamas", + "allow-whitespace": "Leisti tarpus", + "force-reset-password-if-no-valid": "Priverstinai atstatyti slaptažodį, jei jis negalioja", + "force-reset-password-if-no-valid-hint": "Būkite atsargūs įjungdami šią funkciją: naudotojai, kurių slaptažodžiai netinkami, turės juos atstatyti per el. paštą.", + "general-policy": "Bendroji politika", + "max-failed-login-attempts": "Maksimalus neteisingų prisijungimų skaičius prieš paskyros užrakinimą", + "minimum-max-failed-login-attempts-range": "Maksimalus neteisingų prisijungimų skaičius negali būti neigiamas", + "user-lockout-notification-email": "Naudotojo paskyros užrakinimo atveju siųsti pranešimą el. paštu", + "user-activation-token-ttl": "Naudotojo aktyvavimo nuorodos TTL valandomis", + "user-activation-token-ttl-range": "Naudotojo aktyvavimo nuorodos TTL turi būti nuo 1 iki 24 valandų", + "password-reset-token-ttl": "Slaptažodžio atstatymo nuorodos TTL valandomis", + "password-reset-token-ttl-range": "Slaptažodžio atstatymo nuorodos TTL turi būti nuo 1 iki 24 valandų", + "mobile-secret-key-length": "Mobiliojo slaptosios rakto ilgis", + "mobile-secret-key-length-range": "Mobiliojo slaptosios rakto ilgis turi būti teigiamas", + "domain-name": "Domeno pavadinimas", + "domain-name-unique": "Domeno pavadinimas ir protokolas turi būti unikalūs.", + "domain-name-max-length": "Domeno pavadinimas turi būti trumpesnis nei 256 simboliai", + "error-verification-url": "Domeno pavadinime neturi būti simbolių '/' ir ':'. Pavyzdys: thingsboard.io", + "connection-settings": "Ryšio nustatymai", + "oauth2": { + "access-token-uri": "Prieigos rakto URI", + "access-token-uri-required": "Prieigos rakto URI yra privalomas.", + "activate-user": "Aktyvuoti naudotoją", + "add-domain": "Pridėti domeną", + "delete-domain": "Ištrinti domeną", + "add-provider": "Pridėti tiekėją", + "delete-provider": "Ištrinti tiekėją", + "allow-user-creation": "Leisti naudotojų kūrimą", + "always-fullscreen": "Visada viso ekrano režimu", + "authorization-uri": "Autorizacijos URI", + "authorization-uri-required": "Autorizacijos URI yra privalomas.", + "add-client": "Pridėti OAuth 2.0 klientą", + "client-details": "OAuth 2.0 kliento informacija", + "client": "OAuth 2.0 klientas", + "clients": "OAuth 2.0 klientai", + "no-oauth2-clients": "OAuth 2.0 klientų nerasta", + "search-oauth2-clients": "Ieškoti OAuth 2.0 klientų", + "delete-client-title": "Ar tikrai norite ištrinti OAuth 2.0 klientą '{{clientName}}'?", + "delete-client-text": "Būkite atsargūs, po patvirtinimo klientas ir visi susiję duomenys bus negrįžtamai pašalinti.", + "delete-mobile-app-title": "Ar tikrai norite ištrinti mobiliojoje programėlėje '{{applicationName}}'?", + "delete-mobile-app-text": "Būkite atsargūs, po patvirtinimo mobilioji programėlė ir visi susiję duomenys bus negrįžtamai pašalinti.", + "title": "Pavadinimas.", + "client-title-required": "Pavadinimas yra privalomas.", + "client-title-max-length": "Pavadinimas turi būti mažiau nei 100", + "advanced-settings": "Išplėstiniai nustatymai", + "domain-details": "Domeno informacija", + "no-domains": "Domenų nerasta.", + "search-domains": "Ieškoti domenų.", + "mobile-app-details": "Mobiliosios programėlės informacija", + "add-mobile-app": "Pridėti mobiliąją programėlę", + "no-mobile-apps": "No applications configured", + "search-mobile-apps": "Ieškoti mobiliųjų programėlių.", + "send-token": "Siųsti prieigos raktą.", + "create-new": "Sukurti naują", + "client-authentication-method": "Kliento autentifikavimo metodas", + "client-id": "Kliento ID", + "client-id-required": "Kliento ID yra privalomas.", + "client-id-max-length": "Kliento ID turi būti trumpesnis nei 256 simboliai", + "client-secret": "Kliento slaptas raktas", + "client-secret-required": "Kliento slaptas raktas yra privalomas.", + "client-secret-max-length": "Kliento slaptis turi būti trumpesnė nei 2049 simboliai", + "custom-setting": "Pasirinktiniai nustatymai.", + "customer-name-pattern": "Kliento vardo šablonas.", + "customer-name-pattern-max-length": "Kliento vardo šablonas turi būti trumpesnis nei 256 simboliai.", + "default-dashboard-name": "Numatytas prietaisų skydelio pavadinimas", + "default-dashboard-name-max-length": "Numatytojo prietaisų skydelio pavadinimas turi būti trumpesnis nei 256 simboliai", + "delete-domain-text": "Būkite atsargūs — po patvirtinimo domenas ir visi tiekėjo duomenys taps nepasiekiami.", + "delete-domain-title": "Ar tikrai norite ištrinti domeną „{{domainName}}“?", + "delete-registration-text": "Būkite atsargūs, po patvirtinimo tiekėjo duomenys taps nepasiekiami.", + "delete-registration-title": "Ar tikrai norite ištrinti tiekėją „{{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": "Nukopijuoti nukreipimo 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", + "disable": "Išjungti OAuth 2.0 nustatymus", + "edge": "Propaguoti į Edge", + "edge-enable": "Įjungti propagavimą į „Edge“", + "edge-disable": "Išjungti propagavimą į „Edge“", + "domains": "Domains", + "mobile-apps": "Mobile applications", + "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-package-required": "Programėlės paketo pavadinimas yra privalomas.", + "mobile-package-max-length": "Programėlės paketo pavadinimas turi būti trumpesnis nei 256 simboliai.", + "mobile-package-spaces": "Programėlės paketo pavadinimas negali turėti tarpų.", + "mobile-app-secret": "Application secret", + "mobile-app-secret-hint": "Base64 koduota eilutė, vaizduojanti bent 512 bitų duomenų.", + "mobile-app-secret-required": "Programėlės slaptasis raktas yra privalomas.", + "mobile-app-secret-min-length": "Programėlės slaptasis raktas turi būti bent 512 bitų duomenų.", + "mobile-app-secret-base64": "Programos paslaptis turi būti „base64“ formato.", + "invalid-mobile-app-secret": "Application secret must contain only alphanumeric characters and must be between 16 and 2048 characters long.", + "copy-mobile-app-secret": "Nukopijuoti programos slaptą raktą", + "delete-mobile-app": "Ištrinti programos informaciją", + "providers": "Providers", + "platform-web": "Web", + "platform-android": "Android", + "platform-ios": "iOS", + "all-platforms": "Visos platformos", + "smtp-provider": "SMTP provider", + "allowed-platforms": "Leistinos platforms", + "authentication": "Autentifikacija", + "basic": "Bazinis", + "provider": "Provider", + "redirect-url": "Redirect URI", + "domain-name": "Domeno pavadinimas", + "domain-name-required": "Domeno pavadinimas yra privalomas", + "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": "Pasirinktinis", + "generate-access-token": "Generate access token", + "update-access-token": "Update access token", + "access-token-status": "Prieigos rakto būsena:", + "token-status-generated": "generated", + "token-status-not-generated": "not generated" + }, + "smpp-provider": { + "smpp-version": "SMPP versija", + "smpp-host": "SMPP serveris", + "smpp-host-required": "SMPP serveris yra privalomas", + "smpp-port": "SMPP prievadas", + "smpp-port-required": "SMPP prievadas yra privalomas", + "system-id": "Sistemos ID", + "system-id-required": "Sistemos ID yra privalomas", + "password": "Slaptažodis", + "password-required": "Slaptažodis yra privalomas", + "type-settings": "Tipo nustatymai", + "source-settings": "Šaltinio nustatymai", + "destination-settings": "Paskirties nustatymai", + "additional-settings": "Papildomi nustatymai", + "system-type": "Sistemos tipas", + "bind-type": "Susiejimo tipas", + "service-type": "Paslaugos tipas", + "source-address": "Šaltinio adresas", + "source-ton": "Šaltinio TON", + "source-npi": "Šaltinio NPI", + "destination-ton": "Paskirties TON (Numerio tipas)", + "destination-npi": "Paskirties NPI (Numeracijos plano identifikacija)", + "address-range": "Adreso diapazonas", + "coding-scheme": "Koduotės schema", + "bind-type-tx": "Siuntėjas", + "bind-type-rx": "Gavėjas", + "bind-type-trx": "Siuntėjas/Gavėjas (Transceiver)", + "ton-unknown": "Nežinomas", + "ton-international": "Tarptautinis", + "ton-national": "Nacionalinis", + "ton-network-specific": "Tinklo specifinis", + "ton-subscriber-number": "Abonento numeris", + "ton-alphanumeric": "Raidinis-skaitmeninis", + "ton-abbreviated": "Sutrumpintas", + "npi-unknown": "0 - Nežinomas", + "npi-isdn": "1 - ISDN/telefono numeracijos planas (E163/E164)", + "npi-data-numbering-plan": "3 - Duomenų numeracijos planas (X.121)", + "npi-telex-numbering-plan": "4 - Telex numeracijos planas (F.69)", + "npi-land-mobile": "6 - Mobilusis (E.212)", + "npi-national-numbering-plan": "8 - Nacionalinis numeracijos planas", + "npi-private-numbering-plan": "9 - Privatus numeracijos planas", + "npi-ermes-numbering-plan": "10 - ERMES numeracijos planas (ETSI DE/PS 3 01-3)", + "npi-internet": "13 - Internetas (IP)", + "npi-wap-client-id": "18 - WAP kliento ID (nustatomas WAP forumo)", + "scheme-smsc": "0 - SMSC numatytoji abėcėlė (ASCII trumpiems ir ilgiems kodams bei GSM be mokesčio numeriams)", + "scheme-ia5": "1 - IA5 (ASCII trumpiems ir ilgiems kodams, Latin 9 be mokesčio numeriams (ISO-8859-9))", + "scheme-octet-unspecified-2": "2 - Neapibrėžtas aštuonetas (8 bitų dvejetainis)", + "scheme-latin-1": "3 - Lotynų 1 (ISO-8859-1)", + "scheme-octet-unspecified-4": "4 - Neapibrėžtas aštuonetas (8 bitų dvejetainis)", + "scheme-jis": "5 - JIS (X 0208-1990)", + "scheme-cyrillic": "6 - Kirilica (ISO-8859-5)", + "scheme-latin-hebrew": "7 - Lotynų/Hebrajų (ISO-8859-8)", + "scheme-ucs-utf": "8 - UCS2/UTF-16 (ISO/IEC-10646)", + "scheme-pictogram-encoding": "9 - Piktogramų koduotė", + "scheme-music-codes": "10 - Muzikos kodai (ISO-2022-JP)", + "scheme-extended-kanji-jis": "13 - Išplėstinė Kanji JIS (X 0212-1990)", + "scheme-korean-graphic-character-set": "14 - Korėjiečių grafinis simbolių rinkinys (KS C 5601/KS X 1001)" + }, + "queue-select-name": "Pasirinkite eilės pavadinimą", + "queue-name": "Pavadinimas", + "queue-name-required": "Eilės pavadinimas yra privalomas!", + "queues": "Eilės", + "queue-partitions": "Skirsniai", + "queue-submit-strategy": "Pateikimo strategija", + "queue-processing-strategy": "Apdorojimo strategija", + "queue-configuration": "Eilės konfigūracija", + "repository-settings": "Repozitorijos nustatymai", + "repository": "Repozitorija", + "repository-url": "Repozitorijos URL", + "repository-url-required": "Repozitorijos URL yra privalomas.", + "default-branch": "Numatytasis šakos pavadinimas", + "repository-read-only": "Tik skaitymui", + "show-merge-commits": "Rodyti sujungimo (merge) įrašus", + "authentication-settings": "Autentifikavimo nustatymai", + "auth-method": "Autentifikavimo metodas", + "auth-method-username-password": "Slaptažodis / prieigos raktas", + "auth-method-username-password-hint": "GitHub naudotojai privalo naudoti prieigos raktus su rašymo teisėmis į repozitoriją.", + "auth-method-private-key": "Privatus raktas", + "password-access-token": "Slaptažodis / prieigos raktas", + "change-password-access-token": "Pakeisti slaptažodį / prieigos raktą", + "private-key": "Privatus raktas", + "drop-private-key-file-or": "Vilkite ir numeskite privataus rakto failą arba", + "passphrase": "Slaptažodžio frazė", + "enter-passphrase": "Įveskite slaptažodžio frazę", + "change-passphrase": "Pakeisti slaptažodžio frazę", + "check-access": "Patikrinti prieigą", + "check-repository-access-success": "Repozitorijos prieiga sėkmingai patvirtinta!", + "delete-repository-settings-title": "Ar tikrai norite ištrinti repozitorijos nustatymus?", + "delete-repository-settings-text": "Būkite atsargūs, po patvirtinimo repozitorijos nustatymai bus pašalinti ir versijų valdymo funkcija taps nepasiekiama.", + "auto-commit-settings": "Automatinio įrašo nustatymai", + "auto-commit": "Automatinis įrašas (commit)", + "auto-commit-entities": "Automatinio įrašo objektai", + "no-auto-commit-entities-prompt": "Nėra sukonfigūruotų objektų automatiniam įrašui", + "delete-auto-commit-settings-title": "Ar tikrai norite ištrinti automatinio įrašo nustatymus?", + "delete-auto-commit-settings-text": "Būkite atsargūs, po patvirtinimo automatinio įrašo nustatymai bus pašalinti ir funkcija bus išjungta visiems objektams.", + "mobile-app": { + "mobile-app": "Mobilioji programėlė", + "mobile-app-qr-code-widget-settings": "Mobiliosios programėlės QR kodo valdiklio nustatymai", + "applications": "Programėlės", + "default": "Numatytasis", + "custom": "Pasirinktinis", + "android": "Android", + "ios": "iOS", + "appearance": "Išvaizda", + "appearance-on-home-page": "Išvaizda pagrindiniame puslapyje", + "enabled": "Įjungta", + "disabled": "Išjungta", + "badges": "Ženkleliai", + "label": "Etiketė", + "label-required": "Etiketė yra privaloma", + "label-max-length": "Etiketė turi būti ne ilgesnė kaip 50 simbolių", + "right": "Dešinėje", + "left": "Kairėje", + "set": "Nustatyti", + "preview": "Peržiūra", + "connect-mobile-app": "Prijungti mobiliąją programėlę", + "use-system-settings": "Naudoti sistemos nustatymus" + }, + "2fa": { + "2fa": "Dvigubas autentifikavimas", + "available-providers": "Galimi tiekėjai", + "issuer-name": "Leidėjo pavadinimas", + "issuer-name-required": "Leidėjo pavadinimas yra privalomas.", + "max-verification-failures-before-user-lockout": "Maksimalus neteisingų patvirtinimų skaičius prieš naudotojo užrakinimą", + "max-verification-failures-before-user-lockout-pattern": "Maksimalus patvirtinimų skaičius turi būti teigiamas sveikasis skaičius.", + "number-of-checking-attempts": "Tikrinimo bandymų skaičius", + "number-of-checking-attempts-pattern": "Tikrinimo bandymų skaičius turi būti teigiamas sveikasis skaičius.", + "number-of-checking-attempts-required": "Tikrinimo bandymų skaičius yra privalomas.", + "number-of-codes": "Kodų skaičius", + "number-of-codes-pattern": "Kodų skaičius turi būti teigiamas sveikasis skaičius.", + "number-of-codes-required": "Kodų skaičius yra privalomas.", + "provider": "Tiekėjas", + "retry-verification-code-period": "Pakartotinio patvirtinimo kodo laikotarpis (sek)", + "retry-verification-code-period-pattern": "Minimalus laikotarpis yra 5 sek.", + "retry-verification-code-period-required": "Pakartotinio patvirtinimo kodo laikotarpis yra privalomas.", + "total-allowed-time-for-verification": "Bendras leidžiamas patvirtinimo laikas (sek)", + "total-allowed-time-for-verification-pattern": "Minimalus bendras leidžiamas laikas yra 60 sek.", + "total-allowed-time-for-verification-required": "Bendras leidžiamas laikas yra privalomas.", + "use-system-two-factor-auth-settings": "Naudoti sistemos dviejų veiksnių autentifikavimo nustatymus", + "verification-code-check-rate-limit": "Patvirtinimo kodo tikrinimo dažnio limitas", + "verification-code-lifetime": "Patvirtinimo kodo galiojimo laikas (sek)", + "verification-code-lifetime-pattern": "Patvirtinimo kodo galiojimo laikas turi būti teigiamas sveikasis skaičius.", + "verification-code-lifetime-required": "Patvirtinimo kodo galiojimo laikas yra privalomas.", + "verification-message-template": "Patvirtinimo pranešimo šablonas", + "verification-limitations": "Patvirtinimo apribojimai", + "verification-message-template-pattern": "Patvirtinimo pranešime turi būti šablonas: ${code}", + "verification-message-template-required": "Patvirtinimo pranešimo šablonas yra privalomas.", + "within-time": "Per laiką (sek)", + "within-time-pattern": "Laikas turi būti teigiamas sveikasis skaičius.", + "within-time-required": "Laikas yra privalomas." + }, + "jwt": { + "security-settings": "JWT saugumo nustatymai", + "issuer-name": "Leidėjo pavadinimas", + "issuer-name-required": "Leidėjo pavadinimas yra privalomas.", + "signings-key": "Pasirašymo raktas", + "signings-key-hint": "Base64 koduota eilutė, atitinkanti bent 256 bitų duomenis.", + "signings-key-required": "Pasirašymo raktas yra privalomas.", + "signings-key-min-length": "Pasirašymo raktas turi būti bent 256 bitų duomenų ilgio.", + "signings-key-base64": "Pasirašymo raktas turi būti Base64 formatu.", + "expiration-time": "Žetono galiojimo laikas (sek)", + "expiration-time-required": "Žetono galiojimo laikas yra privalomas.", + "expiration-time-max": "Didžiausias leidžiamas laikas – 2147483647 sekundės (68 metai).", + "expiration-time-min": "Mažiausias laikas – 60 sekundžių (1 minutė).", + "refresh-expiration-time": "Atnaujinimo žetono galiojimo laikas (sek)", + "refresh-expiration-time-required": "Atnaujinimo žetono galiojimo laikas yra privalomas.", + "refresh-expiration-time-max": "Didžiausias leidžiamas laikas – 2147483647 sekundės (68 metai).", + "refresh-expiration-time-min": "Mažiausias laikas – 900 sekundžių (15 minučių).", + "refresh-expiration-time-less-token": "Atnaujinimo žetono laikas turi būti ilgesnis už pagrindinio žetono laiką.", + "generate-key": "Generuoti raktą", + "info-header": "Visi naudotojai turės prisijungti iš naujo", + "info-message": "JWT pasirašymo rakto pakeitimas panaikins visų išduotų žetonų galiojimą. Visi naudotojai turės prisijungti iš naujo. Tai taip pat paveiks scenarijus, naudojančius Rest API / Websockets." + }, + "resources": "Ištekliai", + "notifications": "Pranešimai", + "notifications-settings": "Pranešimų nustatymai", + "slack-api-token": "Slack API prieigos raktas", + "slack": "Slack", + "slack-settings": "Slack nustatymai", + "mobile-settings": "Mobiliosios programos nustatymai", + "firebase-service-account-file": "Firebase paslaugos paskyros kredencialų JSON failas", + "select-firebase-service-account-file": "Vilkite ir numeskite savo Firebase paslaugos paskyros kredencialų failą arba ", + "trendz": "PredAna", + "trendz-settings": "PredAna nustatymai", + "trendz-url": "PredAna URL", + "trendz-url-required": "PredAna URL yra privalomas", + "trendz-api-key": "PredAna API raktas", + "trendz-enable": "Įjungti PredAna" + }, + "alarm": { + "alarm": "Įspėjimas", + "alarms": "Įspėjimai", + "all-alarms": "Visi įspėjimai", + "select-alarm": "Pasirinkite įspėjimą", + "no-alarms-matching": "Su '{{entity}}' susijusių įspėjimų nerasta.", + "alarm-required": "Įspėjimas yra privalomas", + "alarm-filter": "Įspėjimų filtras", + "filter": "Filtras", + "alarm-status": "Įspėjimo būsena", + "alarm-status-list": "Įspėjimo būsenų sąrašas", + "any-status": "Visos būsenos", + "search-status": { + "ANY": "Visi", + "ACTIVE": "Aktyvūs", + "CLEARED": "Išvalyti", + "ACK": "Patvirtinti", + "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ą", + "user-deleted": "Vartotojas ištrintas", + "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": "Alarm severity", + "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-types": "Įspėjimų tipai", + "alarm-type-list": "Įspėjimų tipų sąrašas", + "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": "Pseudonimas yra privalomas", + "duplicate-alias": "Toks pseudonimas sistemoje jau egzistuoja.", + "filter-type-single-entity": "Vienas subjektas", + "filter-type-entity-list": "Subjektų sąrašas", + "filter-type-entity-name": "Subjekto pavadinimas", + "filter-type-entity-type": "Subjekto tipas", + "filter-type-state-entity": "Subjektas iš skydelio būsenos", + "filter-type-state-entity-description": "Subjektas iš skydelio būsenos parametrų", + "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 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ų pavadinimas prasideda '{{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ų pavadinimas prasideda '{{prefix}}'", + "filter-type-edge-type": "Krašto (Edge) tipas", + "filter-type-edge-type-description": "Kraštai (Edges), kurių tipas '{{edgeTypes}}'", + "filter-type-edge-type-and-name-description": "Kraštai, kurių tipas '{{edgeTypes}}' ir pavadinimas prasideda '{{prefix}}'", + "filter-type-relations-query": "Ryšių užklausa", + "filter-type-relations-query-description": "{{entities}} turintys {{relationType}} ryšį {{direction}} {{rootEntity}}", + "filter-type-edge-search-query": "Kraštų (Edge) paieškos užklausa", + "filter-type-edge-search-query-description": "Kraštai, kurių tipai {{edgeTypes}}, turintys {{relationType}} ryšį {{direction}} {{rootEntity}}", + "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 naudojimo būsena", + "entity-filter": "Subjektų filtras", + "resolve-multiple": "Apdoroti kelis subjektus vienu metu", + "resolve-multiple-hint": "Įjunkite, jei norite vienu metu atvaizduoti duomenis iš visų filtruotų subjektų.\nJei išjungta, valdiklis rodys duomenis tik iš pasirinkto subjekto.", + "filter-type": "Filtro tipas", + "filter-type-required": "Filtro tipas yra privalomas.", + "entity-filter-no-entity-matched": "Subjektų, atitinkančių filtro kriterijus, nerasta.", + "no-entity-filter-specified": "Subjektų filtras nenustatytas", + "root-state-entity": "Naudoti subjektą iš skydelio būsenos kaip pagrindinį", + "last-level-relation": "Gauti tik paskutinio lygio ryšį", + "root-entity": "Pagrindinis subjektas", + "state-entity-parameter-name": "Būsenos subjekto parametro pavadinimas", + "default-state-entity": "Numatytasis būsenos subjektas", + "default-entity-parameter-name": "Numatytasis parametro pavadinimas", + "query-options": "Užklausos parinktys", + "max-relation-level": "Maksimalus ryšių lygis", + "unlimited-level": "Neribotas lygis", + "state-entity": "Skydelio būsenos subjektas", + "all-entities": "Visi subjektai", + "any-relation": "Visi ryšiai" + }, + "asset": { + "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": "Priskirti turtą klientui", + "assign-asset-to-customer-text": "Pasirinkite turtą, kurį norite priskirti klientui", + "no-assets-text": "Turtas nerastas", + "assign-to-customer-text": "Pasirinkite klientą, kuriam priskiriamas turtas", + "public": "Viešas", + "assignedToCustomer": "Priskirtas klientui", + "make-public": "Neriboti turto matomumo", + "make-private": "Riboti turto matomumą", + "unassign-from-customer": "Atsieti nuo kliento", + "delete": "Pašalinti turtą", + "asset-public": "Turtas yra viešas", + "asset-type": "Turto tipas", + "asset-type-required": "Turto tipas yra privalomas.", + "select-asset-type": "Pasirinkite turto tipą", + "enter-asset-type": "Įveskite turto tipą", + "any-asset": "Bet koks 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": "Etiketė turi būti trumpesnė nei 256 simboliai", + "description": "Aprašymas", + "type": "Tipas", + "type-required": "Tipas yra privalomas.", + "details": "Pastabos", + "events": "Įvykiai", + "add-asset-text": "Pridėti turtą", + "asset-details": "Turto informacija", + "assign-assets": "Priskirti turtą", + "assign-assets-text": "Priskirti { count, plural, =1 {1 turtą} other {# turtus} } klientui", + "assign-asset-to-edge-title": "Priskirti turtą (-us) Edge įrenginiui", + "assign-asset-to-edge-text": "Pasirinkite turtą (-us), kuriuos norite priskirti Edge įrenginiui", + "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": "Priskirti naują turtą", + "delete-asset-title": "Pašalinti turtą '{{assetName}}'?", + "delete-asset-text": "Būkite atsargūs, po patvirtinimo turtas ir visa 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 atsargūs, po patvirtinimo visas turtas ir su juo 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 apriboti turto '{{assetName}}' matomumą?", + "make-private-asset-text": "Po patvirtinimo turtas ir visa su juo susijusi informacija nebebus prieinama kitiems.", + "unassign-asset-title": "Ar tikrai norite atsieti turtą '{{assetName}}'?", + "unassign-asset-text": "Po patvirtinimo turtas bus atsietas, o 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 pasirinktas turtas bus atsietas, o klientas neteks prieigos prie jo.", + "copyId": "Kopijuoti turto ID", + "idCopiedMessage": "Turto ID nukopijuotas į iškarpinę", + "select-asset": "Pasirinkite turtą", + "no-assets-matching": "Turtas, atitinkantis '{{entity}}', nerastas.", + "asset-required": "Turtas yra privalomas", + "name-starts-with": "Turto pavadinimas prasideda nuo", + "help-text": "Naudokite simbolį '%' pagal paieškos poreikį: '%turto_fragmentas%', '%turto_pabaiga', 'turto_pradžia%'.", + "search": "Turto paieška", + "import": "Importuoti turtą", + "asset-file": "Turto failas", + "label": "Etiketė", + "assign-asset-to-edge": "Priskirti turtą (-us) Edge įrenginiui", + "unassign-asset-from-edge": "Atsieti turtą nuo Edge", + "unassign-asset-from-edge-title": "Ar tikrai norite atsieti turtą '{{assetName}}' nuo Edge?", + "unassign-asset-from-edge-text": "Po patvirtinimo turtas bus atsietas ir nebebus pasiekiamas Edge įrenginyje.", + "unassign-assets-from-edge-title": "Ar tikrai norite atsieti { count, plural, =1 {1 turtą} other {# turtus} } nuo Edge?", + "unassign-assets-from-edge-text": "Po patvirtinimo visi pasirinkti turtai bus atsieti ir nebebus prieinami Edge įrenginiuose.", + "selected-assets": "Pasirinkta { count, plural, =1 {1 turtas} other {# turtai} }" + }, + "attribute": { + "attributes": "Atributai", + "latest-telemetry": "Naujausia telemetrija", + "no-latest-telemetry": "Telemetrijos duomenų nėra", + "attributes-scope": "Subjekto atributų sritis", + "scope-telemetry": "Telemetrija", + "scope-latest-telemetry": "Naujausia telemetrija", + "scope-client": "Kliento atributai", + "scope-server": "Serverio atributai", + "scope-shared": "Bendrinami atributai", + "scope-client-short": "Client", + "scope-server-short": "Server", + "scope-shared-short": "Shared", + "scope-latest-short": "Latest", + "scope-any": "Bet kurie", + "add": "Pridėti atributą", + "key": "Raktas", + "key-max-length": "Raktas negali viršyti 256 simbolių", + "last-update-time": "Paskutinio atnaujinimo laikas", + "key-required": "Atributo raktas yra privalomas.", + "value": "Reikšmė", + "value-required": "Atributo reikšmė yra privaloma.", + "telemetry-key-required": "Telemetrijos raktas yra privalomas.", + "telemetry-value-required": "Telemetrijos reikšmė yra privaloma.", + "delete-attributes-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 atributą} other {# atributus} }?", + "delete-attributes-text": "Būkite atsargūs — 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": "Kitas 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 įrašas} other {# telemetrijos įrašai} }", + "no-attributes-text": "Atributų nėra", + "no-telemetry-text": "Telemetrijos duomenų nėra", + "copy-key": "Kopijuoti raktą", + "add-telemetry": "Pridėti telemetriją", + "copy-value": "Kopijuoti reikšmę", + "delete-timeseries": { + "start-time": "Pradžios laikas", + "ends-on": "Pabaigos laikas", + "strategy": "Strategija", + "delete-strategy": "Šalinimo strategija", + "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 laikotarpiui", + "rewrite-latest-value": "Perrašyti naujausią reikšmę" + } + }, + "api-usage": { + "api-features": "API funkcijos", + "api-usage": "API naudojimas", + "alarm": "Įspėjimas", + "alarms-created": "Sukurti įspėjimai", + "queue-stats": "Eilės statistika", + "processing-failures-and-timeouts": "Apdorojimo klaidos ir laiko limitai", + "exceptions": "Išimtys", + "alarms-created-daily-activity": "Įspėjimų dienos veikla", + "alarms-created-hourly-activity": "Įspėjimų valandinė veikla", + "alarms-created-monthly-activity": "Įspėjimų mėnesinė veikla", + "data-points": "Duomenų taškai", + "data-points-storage-days": "Duomenų taškų saugojimo dienos", + "device-api": "Įrenginio API", + "email": "El. paštas", + "email-messages": "El. laiškai", + "email-messages-daily-activity": "El. laiškų dienos veikla", + "email-messages-monthly-activity": "El. laiškų mėnesinė veikla", + "executions": "Vykdymai", + "scripts": "Skriptai", + "scripts-hourly-activity": "Skriptų valandinė veikla", + "scripts-daily-activity": "Skriptų dienos veikla", + "scripts-monthly-activity": "Skriptų mėnesinė veikla", + "javascript": "JavaScript", + "javascript-executions": "JavaScript vykdymai", + "tbel": "TBEL", + "tbel-executions": "TBEL vykdymai", + "latest-error": "Naujausia klaida", + "messages": "Pranešimai", + "notifications": "Pranešimai", + "notifications-email-sms": "Pranešimai (El. paštas / SMS)", + "notifications-hourly-activity": "Pranešimų valandinė veikla", + "permanent-failures": "${entityName} nuolatinės klaidos", + "permanent-timeouts": "${entityName} nuolatiniai laiko limitai", + "processing-failures": "${entityName} apdorojimo klaidos", + "processing-timeouts": "${entityName} apdorojimo laiko limitai", + "rule-chain": "Taisyklių grandinė (Rule Chain)", + "rule-engine": "Taisyklių variklis (Rule Engine)", + "rule-engine-daily-activity": "Taisyklių variklio dienos veikla", + "rule-engine-executions": "Taisyklių variklio vykdymai", + "rule-engine-hourly-activity": "Taisyklių variklio valandinė veikla", + "rule-engine-monthly-activity": "Taisyklių variklio mėnesinė veikla", + "rule-engine-statistics": "Taisyklių variklio statistika", + "rule-node": "Taisyklių mazgas (Rule Node)", + "sms": "SMS", + "sms-messages": "SMS žinutės", + "sms-messages-daily-activity": "SMS žinučių dienos veikla", + "sms-messages-monthly-activity": "SMS žinučių mėnesinė veikla", + "successful": "${entityName} sėkmingi vykdymai", + "telemetry": "Telemetrija", + "telemetry-persistence": "Telemetrijos įrašymas", + "telemetry-persistence-daily-activity": "Telemetrijos įrašymo dienos veikla", + "telemetry-persistence-hourly-activity": "Telemetrijos įrašymo valandinė veikla", + "telemetry-persistence-monthly-activity": "Telemetrijos įrašymo mėnesinė veikla", + "transport": "Transportas", + "transport-daily-activity": "Transporto dienos veikla", + "transport-data-points": "Transporto duomenų taškai", + "transport-hourly-activity": "Transporto valandinė veikla", + "transport-messages": "Transporto pranešimai", + "transport-monthly-activity": "Transporto mėnesinė veikla", + "view-details": "Peržiūrėti detales", + "view-statistics": "Peržiūrėti statistiką" + }, + "api-limit": { + "cassandra-write-queries-core": "REST API Cassandra rašymo užklausos", + "cassandra-read-queries-core": "REST API ir WS telemetrijos Cassandra skaitymo užklausos", + "cassandra-write-queries-rule-engine": "Taisyklių variklio (Rule Engine) telemetrijos Cassandra rašymo užklausos", + "cassandra-read-queries-rule-engine": "Taisyklių variklio (Rule Engine) telemetrijos Cassandra skaitymo užklausos", + "cassandra-write-queries-monolith": "Monolitinės telemetrijos Cassandra rašymo užklausos", + "cassandra-read-queries-monolith": "Monolitinės telemetrijos Cassandra skaitymo užklausos", + "entity-version-creation": "Subjekto versijos kūrimas", + "entity-version-load": "Subjekto versijos įkėlimas", + "notification-requests": "Pranešimų užklausos", + "notification-requests-per-rule": "Pranešimų užklausos pagal taisyklę", + "rest-api-requests": "REST API užklausos", + "rest-api-requests-per-customer": "REST API užklausos pagal klientą", + "transport-messages": "Transporto pranešimai", + "transport-messages-per-device": "Transporto pranešimai pagal įrenginį", + "transport-messages-per-gateway": "Transporto pranešimai pagal šliuzą (gateway)", + "transport-messages-per-gateway-device": "Transporto pranešimai pagal šliuzo įrenginį", + "ws-updates-per-session": "WS atnaujinimai per sesiją", + "edge-events": "Edge įvykiai", + "edge-events-per-edge": "Edge įvykiai pagal Edge", + "edge-uplink-messages": "Edge uplink pranešimai", + "edge-uplink-messages-per-edge": "Edge uplink pranešimai pagal Edge" + }, + "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": "Atributai 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": "Priskirta Edge įrenginiui", + "type-unassigned-from-edge": "Atsieta nuo Edge įrenginio", + "type-activated": "Aktyvuota", + "type-suspended": "Sustabdyta", + "type-credentials-read": "Įgaliojimų skaitymas", + "type-attributes-read": "Atributų skaitymas", + "type-relation-add-or-update": "Ryšys pridėtas arba atnaujintas", + "type-relation-delete": "Ryšys panaikintas", + "type-relations-delete": "Visi ryšiai panaikinti", + "type-alarm-ack": "Įspėjimas patvirtintas", + "type-alarm-clear": "Įspėjimas išvalytas", + "type-alarm-delete": "Įspėjimas pašalintas", + "type-alarm-assign": "Įspėjimas priskirtas", + "type-alarm-unassign": "Įspėjimas atsietas", + "type-added-comment": "Pridėtas komentaras", + "type-updated-comment": "Atnaujintas komentaras", + "type-deleted-comment": "Pašalintas komentaras", + "type-login": "Prisijungimas", + "type-logout": "Atsijungimas", + "type-lockout": "Užblokuotas", + "status-success": "Sėkminga", + "status-failure": "Nesėkminga", + "audit-log-details": "Audito žurnalo informacija", + "no-audit-logs-prompt": "Audito žurnalo įrašų nėra", + "action-data": "Veiksmų informacija", + "failure-details": "Nesėkmės informacija", + "search": "Žurnalo įrašų paieška", + "clear-search": "Išvalyti paiešką", + "type-assigned-from-tenant": "Priskirta iš nuomotojo", + "type-assigned-to-tenant": "Priskirta nuomotojui", + "type-provision-success": "Įrenginys sėkmingai įtrauktas (provisioned)", + "type-provision-failure": "Įrenginio įtraukimas nepavyko", + "type-timeseries-updated": "Telemetrija atnaujinta", + "type-timeseries-deleted": "Telemetrija pašalinta", + "type-sms-sent": "SMS išsiųsta" + }, + "debug-settings": { + "label": "Derinimo konfigūracija", + "on-failure": "Tik klaidos (24/7)", + "all-messages": "Visos žinutės ({{time}})", + "failures": "Klaidos", + "entity": "objektas", + "hint": { + "main-limited": "Per {{time}} bus įrašyta ne daugiau kaip {{msg}} derinimo žinučių iš {{entity}}.", + "on-failure": "Registruoti tik klaidų žinutes.", + "all-messages": "Registruoti visas derinimo žinutes." + } + }, + "calculated-fields": { + "expression": "Išraiška", + "no-found": "Apskaičiuotų laukų nerasta", + "list": "{ count, plural, =1 {Vienas apskaičiuotas laukas} other {# apskaičiuotų laukų sąrašas} }", + "selected-fields": "{ count, plural, =1 {1 apskaičiuotas laukas} other {# apskaičiuoti laukai} } pasirinkta", + "type": { + "simple": "Paprastas", + "script": "Skriptas" + }, + "arguments": "Argumentai", + "decimals-by-default": "Numatytasis dešimtainių skaičius", + "debugging": "Apskaičiuoto lauko derinimas", + "argument-name": "Argumento pavadinimas", + "datasource": "Duomenų šaltinis", + "add-argument": "Pridėti argumentą", + "test-script-function": "Išbandyti skripto funkciją", + "no-arguments": "Argumentai nesukonfigūruoti", + "argument-settings": "Argumento nustatymai", + "argument-current": "Dabartinis subjektas", + "argument-current-tenant": "Dabartinis valdytojas", + "argument-device": "Įrenginys", + "argument-asset": "Turtas", + "argument-customer": "Klientas", + "argument-tenant": "Dabartinis valdytojas", + "argument-type": "Argumento tipas", + "see-debug-events": "Peržiūrėti derinimo įvykius", + "attribute": "Atributas", + "copy-argument-name": "Kopijuoti argumento pavadinimą", + "timeseries-key": "Laiko sekos raktas", + "device-name": "Įrenginio pavadinimas", + "latest-telemetry": "Naujausia telemetrija", + "rolling": "Laiko sekos kaupimas", + "attribute-scope": "Atributo sritis", + "server-attributes": "Serverio atributai", + "client-attributes": "Kliento atributai", + "shared-attributes": "Bendrinami atributai", + "attribute-key": "Atributo raktas", + "default-value": "Numatytoji reikšmė", + "limit": "Maksimalių reikšmių skaičius", + "time-window": "Laiko intervalas", + "customer-name": "Kliento pavadinimas", + "asset-name": "Turto pavadinimas", + "timeseries": "Laiko seka", + "output": "Rezultatas", + "create": "Sukurti naują apskaičiuotą lauką", + "file": "Apskaičiuoto lauko failas", + "invalid-file-error": "Netinkamas failo formatas. Įsitikinkite, kad failas yra tinkamas JSON formatas.", + "import": "Importuoti apskaičiuotą lauką", + "export": "Eksportuoti apskaičiuotą lauką", + "export-failed-error": "Nepavyko eksportuoti apskaičiuoto lauko: {{error}}", + "output-type": "Rezultato tipas", + "delete-title": "Ar tikrai norite pašalinti apskaičiuotą lauką '{{title}}'?", + "delete-text": "Būkite atsargūs — po patvirtinimo apskaičiuotas laukas ir visa susijusi informacija bus negrįžtamai pašalinta.", + "delete-multiple-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 apskaičiuotą lauką} other {# apskaičiuotus laukus} }?", + "delete-multiple-text": "Būkite atsargūs — po patvirtinimo visi pasirinkti apskaičiuoti laukai bus pašalinti kartu su visa susijusia informacija.", + "test-with-this-message": "Išbandyti su šia žinute", + "use-latest-timestamp": "Naudoti naujausią laiko žymą", + "hint": { + "arguments-simple-with-rolling": "Paprasto tipo apskaičiuotas laukas neturėtų turėti laiko sekos kaupimo tipo raktų.", + "arguments-empty": "Argumentai negali būti tušti.", + "expression-required": "Reikalinga išraiška.", + "expression-invalid": "Išraiška netinkama.", + "expression-max-length": "Išraiškos ilgis turi būti trumpesnis nei 255 simboliai.", + "argument-name-required": "Argumento pavadinimas yra būtinas.", + "argument-name-pattern": "Argumento pavadinimas netinkamas.", + "argument-name-duplicate": "Toks argumento pavadinimas jau egzistuoja.", + "argument-name-max-length": "Argumento pavadinimas turi būti trumpesnis nei 256 simboliai.", + "argument-name-forbidden": "Argumento pavadinimas yra rezervuotas ir negali būti naudojamas.", + "argument-type-required": "Reikalingas argumento tipas.", + "max-args": "Pasiektas didžiausias leidžiamų argumentų skaičius.", + "decimals-range": "Numatytasis dešimtainių skaičius turi būti tarp 0 ir 15.", + "expression": "Numatytoji išraiška parodo, kaip paversti temperatūrą iš Farenheito į Celsijų.", + "arguments-entity-not-found": "Argumento paskirties subjektas nerastas.", + "use-latest-timestamp": "Jei įjungta, apskaičiuota reikšmė bus išsaugota naudojant naujausią argumentų telemetrijos laiko žymą, o ne serverio laiką." + } + }, + "ai-models": { + "ai-models": "AI modeliai", + "ai-model": "AI modelis", + "model": "Modelis", + "name": "Pavadinimas", + "ai-provider": "AI tiekėjas", + "no-found": "AI modelių nerasta", + "list": "{ count, plural, =1 {Vienas modelis} other {# modelių sąrašas} }", + "selected-fields": "{ count, plural, =1 {1 modelis} other {# modeliai} } pasirinkta", + "add": "Pridėti modelį", + "delete-model-title": "Ar tikrai norite pašalinti modelį '{{modelName}}'?", + "delete-model-text": "Būkite atsargūs — po patvirtinimo modelis ir visa su juo susijusi informacija bus negrįžtamai pašalinta.", + "delete-models-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 modelį} other {# modelius} }?", + "delete-models-text": "Būkite atsargūs — po patvirtinimo visi pasirinkti modeliai bus pašalinti kartu su visa susijusia informacija.", + "ai-providers": { + "openai": "OpenAI", + "azure-openai": "Azure OpenAI", + "google-ai-gemini": "Google AI Gemini", + "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "mistral-ai": "Mistral AI", + "anthropic": "Anthropic", + "amazon-bedrock": "Amazon Bedrock", + "github-models": "GitHub Modeliai" + }, + "name-required": "Pavadinimas yra privalomas.", + "name-max-length": "Pavadinimas turi būti ne ilgesnis nei 255 simboliai.", + "provider": "Tiekėjas", + "api-key": "API raktas", + "api-key-required": "API raktas yra privalomas.", + "project-id": "Projekto ID", + "project-id-required": "Projekto ID yra privalomas.", + "location": "Vieta", + "location-required": "Vieta yra privaloma.", + "service-account-key-file": "Paslaugos paskyros rakto failas", + "service-account-key-file-required": "Paslaugos paskyros rakto failas yra privalomas.", + "no-file": "Failas nepasirinktas.", + "drop-file": "Vilkite failą arba spustelėkite, kad pasirinktumėte įkelti failą.", + "personal-access-token": "Asmeninis prieigos raktas", + "personal-access-token-required": "Asmeninis prieigos raktas yra privalomas.", + "configuration": "Konfigūracija", + "model-id": "Modelio ID", + "model-id-required": "Modelio ID yra privalomas.", + "deployment-name": "Diegimo pavadinimas", + "deployment-name-required": "Diegimo pavadinimas yra privalomas.", + "set": "Nustatyti", + "region": "Regionas", + "region-required": "Regionas yra privalomas.", + "access-key-id": "Prieigos rakto ID", + "access-key-id-required": "Prieigos rakto ID yra privalomas.", + "secret-access-key": "Slaptas prieigos raktas", + "secret-access-key-required": "Slaptas prieigos raktas yra privalomas.", + "temperature": "Temperatūra", + "temperature-hint": "Nustato modelio atsakymo atsitiktinumo lygį. Didesnės reikšmės didina atsitiktinumą, mažesnės – mažina.", + "temperature-min": "Turi būti 0 arba daugiau.", + "top-p": "Top P", + "top-p-hint": "Sukuria baseiną tikėtiniausių žetonų, iš kurių modelis gali rinktis. Didesnės reikšmės sukuria didesnį ir įvairesnį baseiną, mažesnės – siauresnį.", + "top-p-min-max": "Turi būti didesnis nei 0 ir neviršyti 1.", + "top-k": "Top K", + "top-k-hint": "Apriboja modelio pasirinkimus iki fiksuoto „K“ tikėtiniausių žetonų rinkinio.", + "top-k-min": "Turi būti 0 arba daugiau.", + "presence-penalty": "Buvo pasirodymo bauda", + "presence-penalty-hint": "Taikoma fiksuota bauda žetonui, jei jis jau buvo panaudotas tekste.", + "frequency-penalty": "Dažnio bauda", + "frequency-penalty-hint": "Taikoma bauda žetono tikimybei, kuri didėja priklausomai nuo jo pasikartojimo dažnio tekste.", + "max-output-tokens": "Maksimalus išvesties žetonų skaičius", + "max-output-tokens-hint": "Nustato didžiausią žetonų skaičių, kurį modelis gali sugeneruoti viename atsakyme.", + "endpoint": "Pabaigos taškas (Endpoint)", + "endpoint-required": "Pabaigos taškas yra privalomas.", + "service-version": "Paslaugos versija", + "check-connectivity": "Tikrinti ryšį", + "check-connectivity-success": "Bandomasis užklausimas sėkmingas", + "check-connectivity-failed": "Bandomasis užklausimas nepavyko", + "no-model-matching": "Modelių, atitinkančių '{{entity}}', nerasta.", + "model-required": "Modelis yra privalomas.", + "no-model-text": "Modelių nerasta." + }, + "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", + "country-required": "Country is required.", + "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", + "no-country-found": "No countries found.", + "no-country-matching": "No country matching '{{country}}' were found.", + "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": { + "name": "Pavadinimas", + "type": "Tipas", + "general": "Bendra informacija", + "username": "Vartotojo vardas", + "password": "Slaptažodis", + "data": "Duomenys", + "timestamp": "Laiko žyma", + "enter-username": "Įveskite vartotojo vardą", + "enter-password": "Įveskite slaptažodį", + "enter-search": "Įveskite paieškos kriterijus", + "created-time": "Sukūrimo laikas", + "disabled": "Išjungta", + "loading": "Įkeliama...", + "proceed": "Tęsti", + "open-details-page": "Atidaryti informacijos puslapį", + "not-found": "Nerasta", + "value": "Reikšmė", + "documentation": "Dokumentacija", + "time-left": "Likęs laikas: {{time}}", + "output": "Išvestis", + "suffix": { + "s": "s", + "ms": "ms" + }, + "hint": { + "name-required": "Pavadinimas yra privalomas.", + "name-pattern": "Pavadinimas netinkamas.", + "name-max-length": "Pavadinimas turi būti trumpesnis nei 256 simboliai.", + "title-required": "Antraštė yra privaloma.", + "title-pattern": "Antraštė netinkama.", + "title-max-length": "Antraštė turi būti trumpesnė nei 256 simboliai.", + "key-required": "Raktas yra privalomas.", + "key-pattern": "Raktas netinkamas.", + "key-max-length": "Raktas turi būti trumpesnis nei 256 simboliai." + }, + "required-fields": "Trūksta privalomų laukų" + }, + "content-type": { + "json": "Json", + "text": "Tekstas", + "binary": "Binarinis (Base64)" + }, + "color": { + "color": "Spalva" + }, + "customer": { + "customer": "Klientas", + "customers": "Klientai", + "management": "Klientų valdymas", + "dashboard": "Kliento skydelis", + "dashboards": "Klientų 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-users": "Kliento vartotojai", + "manage-customer-devices": "Kliento įrenginiai", + "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": "Kliento Edge įrenginiai", + "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-users": "Vartotojai", + "manage-assets": "Turtas", + "manage-devices": "Įrenginiai", + "manage-dashboards": "Skydeliai", + "title": "Pavadinimas", + "title-required": "Pavadinimas yra privalomas.", + "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}}', nerasta.", + "customer-required": "Klientas yra privalomas", + "select-default-customer": "Pasirinkite numatytąjį klientą", + "default-customer": "Numatytasis klientas", + "default-customer-required": "Numatytasis klientas būtinas, norint suderinti prietaisų skydelį valdytojo lygiu", + "search": "Klientų paieška", + "selected-customers": "Pasirinkta { count, plural, =1 {1 klientas} other {# klientai} }", + "edges": "Kliento Edge įrenginiai", + "manage-edges": "Valdyti Edge įrenginius" + }, + "css-size": { + "size-value-required": "Dydžio reikšmė yra privaloma", + "invalid-size-value": "Neteisinga dydžio reikšmė" + }, + "date": { + "last-update-n-ago": "Paskutinis atnaujinimas prieš N", + "last-update-n-ago-text": "Paskutinis atnaujinimas prieš {{ agoText }}", + "custom-date": "Pasirinkta data", + "format": "Formatas", + "preview": "Peržiūra", + "auto": "Automatinis", + "time-granularity-formats": "Laiko granuliacijos formatai", + "unit-year": "Metai", + "unit-month": "Mėnesiai", + "unit-day": "Dienos", + "unit-hour": "Valandos", + "unit-minute": "Minutės", + "unit-second": "Sekundės", + "unit-millisecond": "Milisekundės" + }, + "datetime": { + "date-from": "Data nuo", + "time-from": "Laikas nuo", + "date-to": "Data iki", + "time-to": "Laikas iki", + "from": "Nuo", + "to": "Iki" + }, + "dashboard": { + "dashboard": "Skydelis", + "dashboards": "Skydeliai", + "management": "Skydelių valdymas", + "view-dashboards": "Perž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 klientui", + "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ėti valdiklį", + "title": "Pavadinimas", + "image": "Skydelio paveikslėlis", + "mobile-app-settings": "Mobiliosios programėlės nustatymai", + "mobile-order": "Skydelio eiliškumas mobiliojoje programėlėje", + "mobile-hide": "Skydelį slėpti mobiliojoje programėlėje", + "update-image": "Atnaujinti skydelio paveikslėlį", + "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": "Skydelio 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 patvirtinimo 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.", + "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}}' sukurta naudojant ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' sukurta naudojant ThingsBoard", + "select-dashboard": "Pasirinkti skydelį", + "no-dashboards-matching": "Skydelio, atitinkančio '{{entity}}', nėra.", + "dashboard-required": "Skydelis būtinas.", + "select-existing": "Pasirinkti esamą skydelį", + "create-new": "Sukurti naują skydelį", + "new-dashboard-title": "Naujo 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", + "move-all-widgets": "Perkelti visus valdiklius", + "move-by": "Perkelti per", + "cols": "Stulpeliai", + "rows": "Eilutės", + "layout": "Išdėstymas", + "layout-type-default": "Numatytasis", + "layout-type-scada": "SCADA", + "layout-type-divider": "Skyriklis", + "layout-settings-type": "Išdėstymo nustatymai: {{ type }} lūžio taškas", + "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.", + "min-layout-width": "Minimalus išdėstymo plotis", + "columns-suffix": "stulpeliai", + "widgets-margins": "Paraštės tarp valdiklių dydis", + "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ų.", + "row-height": "Eilutės aukštis", + "row-height-required": "Eilutės aukščio reikšmė yra privaloma.", + "min-row-height-message": "Mažiausias eilutės aukštis – 5 pikseliai.", + "max-row-height-message": "Didžiausias eilutės aukštis – 200 pikselių.", + "display-first-in-mobile-view": "Rodyti pirmą mobiliajame rodinyje", + "title-settings": "Pavadinimo nustatymai", + "display-title": "Rodyti skydelio pavadinimą", + "title-color": "Pavadinimo spalva", + "toolbar-settings": "Įrankių juostos nustatymai", + "hide-toolbar": "Paslėpti įrankių juostą", + "toolbar-always-open": "Įrankių juostą laikyti atidarytą", + "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": "Rodyti skydelio paveikslėlio atnaujinimą", + "dashboard-logo-settings": "Skydelio logotipo nustatymai", + "display-dashboard-logo": "Rodyti logotipą skydelio viso ekrano režime", + "dashboard-logo-image": "Skydelio logotipo paveikslėlis", + "advanced-settings": "Išplėstiniai nustatymai", + "dashboard-css": "Skydelio CSS", + "import": "Importuoti skydelį", + "export": "Eksportuoti skydelį", + "export-failed-error": "Skydelio eksportuoti nepavyko: {{error}}", + "export-prompt": "Įterpti skydelio paveikslėlius ir išteklius", + "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", + "state-controller-default": "static (pasenęs)", + "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ą", + "unassign-dashboard-from-edge-text": "Po patvirtinimo skydelis bus atsietas ir nebus pasiekiamas Edge įrenginio.", + "unassign-dashboards-from-edge-title": "Ar tikrai norite atsieti { count, plural, =1 {1 skydelį} other {# skydelius} }?", + "unassign-dashboards-from-edge-text": "Po patvirtinimo visi pasirinkti skydeliai bus atsieti ir nebus pasiekiami Edge įrenginio.", + "assign-dashboard-to-edge": "Priskirti skydelį (-ius) Edge įrenginiui", + "assign-dashboard-to-edge-text": "Pasirinkite skydelius, kuriuos priskirti Edge įrenginiui", + "non-existent-dashboard-state-error": "Skydelio būsena su id „{{ stateId }}“ nerasta", + "edit-mode": "Redagavimo režimas", + "duplicate-state-action": "Dubliuoti būseną", + "breakpoint-value": "Lūžio taškas ({{ value }})", + "breakpoints-id": { + "default": "Numatytasis", + "xs": "Mobilusis (xs)", + "sm": "Planšetė (sm)", + "md": "Nešiojamas (md)", + "lg": "Stalinis (lg)", + "xl": "Stalinis (xl)" + }, + "view-format-type-grid": "Tinklelis", + "view-format-type-list": "Sąrašas", + "view-format": "Rodinio formatas" + }, + "datakey": { + "settings": "Nustatymai", + "general": "Bendra", + "advanced": "Pažangūs nustatymai", + "key": "Raktas", + "keys": "Raktai", + "label": "Etiketė", + "color": "Spalva", + "units": "Vienetai (simboliai, rodomi šalia reikšmės)", + "decimals": "Skaitmenų po kablelio kiekis", + "data-generation-func": "Duomenų generavimo funkcija", + "use-data-post-processing-func": "Naudoti papildomo duomenų apdorojimo funkciją", + "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 arba atributai būtini.", + "alarm-fields-timeseries-or-attributes-required": "Įspėjimo laukai arba subjekto telemetrija/atributai būtini.", + "maximum-timeseries-or-attributes": "Leidžiama daugiausiai { count, plural, =1 {1 telemetrija/atributas} other {# telemetrijų/atributų} }.", + "alarm-fields-required": "Įspėjimo laukai būtini.", + "function-types": "Funkcijų tipai", + "function-type": "Funkcijos tipas", + "function-types-required": "Funkcijos tipas būtinas.", + "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": "Įspėjimo rakto funkcijos", + "alarm-key-function": "Įspėjimo rakto funkcija", + "latest-keys": "Naujausi duomenų raktai", + "latest-key": "Naujausias duomenų raktas", + "latest-key-functions": "Naujausio rakto funkcijos", + "latest-key-function": "Naujausio rakto funkcija", + "timeseries-keys": "Telemetrijos duomenų raktai", + "timeseries-key": "Telemetrijos duomenų raktas", + "timeseries-key-functions": "Telemetrijos rakto funkcijos", + "timeseries-key-function": "Telemetrijos rakto funkcija", + "maximum-function-types": "Daugiausia 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": "Dėl našumo priežasčių agreguotų reikšmių skaičiavimas galimas tik fiksuotiems laiko intervalams, pvz., 'dabartinė diena', 'dabartinis mėnuo' ir pan. Tai netaikoma slankiesiems intervalams, kaip 'paskutinės 30 min' ar 'paskutinės 24 val'.", + "aggregation-type-none-hint": "Naudoti naujausią reikšmę.", + "aggregation-type-min-hint": "Rasti mažiausią reikšmę tarp duomenų taškų pasirinktoje laiko atkarpoje.", + "aggregation-type-max-hint": "Rasti didžiausią reikšmę tarp duomenų taškų pasirinktoje laiko atkarpoje.", + "aggregation-type-avg-hint": "Apskaičiuoti vidurkį tarp duomenų taškų pasirinktoje laiko atkarpoje.", + "aggregation-type-sum-hint": "Suskaičiuoti visų duomenų taškų reikšmių sumą pasirinktoje laiko atkarpoje.", + "aggregation-type-count-hint": "Duomenų taškų skaičius pasirinktoje laiko atkarpoje.", + "delta-calculation": "Delta skaičiavimas", + "enable-delta-calculation": "Įjungti delta skaičiavimą", + "enable-delta-calculation-hint": "Kai įjungta, rakto reikšmė skaičiuojama pagal agreguotas reikšmes pasirinktam laikotarpiui ir lyginimo periodui. Dėl našumo delta skaičiavimas galimas tik istoriniams laikotarpiams, o ne realaus laiko reikšmėms. Pvz., galima apskaičiuoti skirtumą tarp vakarykščio ir užvakar dienos energijos suvartojimo.", + "delta-calculation-result": "Delta skaičiavimo rezultatas", + "delta-calculation-result-previous-value": "Ankstesnė reikšmė", + "delta-calculation-result-delta-absolute": "Delta (absoliuti)", + "delta-calculation-result-delta-percent": "Delta (procentinė)", + "source": "Šaltinis", + "latest": "Naujausia", + "latest-value": "Naujausia reikšmė", + "delta": "delta", + "percent": "procentai", + "absolute": "absoliuti" + }, + "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": { + "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": "Sertifikatas PEM formate", + "certificate-pem-format-required": "Certificate is required.", + "copy-access-token": "Kopijuoti prieigos raktą", + "copy-certificate": "Kopijuoti sertifikatą", + "copy-client-id": "Kopijuoti kliento ID", + "copy-user-name": "Kopijuoti vartotojo vardą", + "copy-password": "Kopijuoti slaptažodį", + "generate-client-id": "Generuoti kliento ID", + "generate-user-name": "Generuoti vartotojo vardą", + "generate-password": "Generuoti slaptažodį", + "generate-access-token": "Generuoti prieigos raktą", + "lwm2m-security-config": { + "identity": "Kliento identifikatorius", + "identity-required": "Kliento identifikatorius būtinas.", + "identity-tooltip": "PSK identifikatorius yra savavališkai parinktas iki 128 baitų identifikatorius, kaip aprašyta standarte [RFC7925].\nPSK identifikatorius pirmiausia turi būti konvertuotas į simbolių eilutę ir tada užkoduotas į baitus naudojant UTF-8.", + "client-key": "Kliento raktas", + "client-key-required": "Kliento raktas būtinas.", + "client-key-tooltip-prk": "RPK viešasis raktas arba ID turi atitikti standartą [RFC7250] ir būti užkoduotas Base64 formatu!", + "client-key-tooltip-psk": "PSK raktas turi atitikti standartą [RFC4279] ir būti HexDec formatu: 32, 64 arba 128 simboliai!", + "endpoint": "Kliento galinio taško pavadinimas (Endpoint)", + "endpoint-required": "Kliento galinio taško pavadinimas būtinas.", + "client-public-key": "Kliento viešasis raktas", + "client-public-key-hint": "Jei viešasis raktas nenurodytas, bus naudojamas patikimas sertifikatas.", + "client-public-key-tooltip": "X509 viešasis raktas turi būti DER koduotas X509v3 formatu, naudoti tik EC algoritmą ir būti užkoduotas Base64 formatu!", + "mode": "Saugumo konfigūracijos režimas", + "client-tab": "Kliento saugumo konfigūracija", + "client-certificate": "Kliento sertifikatas", + "bootstrap-tab": "Bootstrap klientas", + "bootstrap-server": "Bootstrap serveris", + "lwm2m-server": "LwM2M serveris", + "client-publicKey-or-id": "Kliento viešasis raktas arba ID", + "client-publicKey-or-id-required": "Kliento viešasis raktas arba ID būtinas.", + "client-publicKey-or-id-tooltip-psk": "PSK identifikatorius yra savavališkai parinktas iki 128 baitų identifikatorius, kaip aprašyta standarte [RFC7925].\nPSK identifikatorius pirmiausia turi būti konvertuotas į simbolių eilutę ir tada užkoduotas į baitus naudojant UTF-8.", + "client-publicKey-or-id-tooltip-rpk": "RPK viešasis raktas arba ID turi atitikti standartą [RFC7250] ir būti užkoduotas Base64 formatu!", + "client-publicKey-or-id-tooltip-x509": "X509 viešasis raktas turi būti DER koduotas X509v3 formatu, naudoti tik EC algoritmą ir būti užkoduotas Base64 formatu!", + "client-secret-key": "Kliento slaptasis raktas", + "client-secret-key-required": "Kliento slaptasis raktas būtinas.", + "client-secret-key-tooltip-psk": "PSK raktas turi atitikti standartą [RFC4279] ir būti HexDec formatu: 32, 64 arba 128 simboliai!", + "client-secret-key-tooltip-prk": "RPK slaptasis raktas turi būti PKCS_8 formatu (DER kodavimas, standartas [RFC5958]) ir būti užkoduotas Base64 formatu!", + "client-secret-key-tooltip-x509": "X509 slaptasis raktas turi būti PKCS_8 formatu (DER kodavimas, standartas [RFC5958]) ir būti užkoduotas Base64 formatu!" + }, + "client-id": "Kliento ID", + "client-id-pattern": "Yra netinkamas simbolis.", + "user-name": "Vartotojo vardas", + "user-name-required": "Vartotojo vardas būtinas.", + "client-id-or-user-name-necessary": "Kliento ID ir/arba vartotojo vardas būtini.", + "password": "Slaptažodis", + "secret": "Slaptas raktas", + "secret-required": "Slaptas raktas būtinas.", + "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-profile-type-list-empty": "Turi būti pasirinktas bent vienas įrenginio profilis.", + "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": "Kopijuoti MQTT autentifikacijos duomenis", + "idCopiedMessage": "Įrenginio ID nukopijuotas į iškarpinę.", + "accessTokenCopiedMessage": "Įrenginio prieigos raktas nukopijuotas į iškarpinę.", + "mqtt-authentication-copied-message": "Įrenginio MQTT autentifikacijos duomenys nukopijuoti į iškarpinę.", + "assignedToCustomer": "Priskirtas klientui", + "unable-delete-device-alias-title": "Nepavyko panaikinti įrenginio pseudonimo", + "unable-delete-device-alias-text": "Įrenginio pseudonimo '{{deviceAlias}}' panaikinti nepavyko, nes jis naudojamas šiuose valdikliuose:
    {{widgetsList}}", + "is-gateway": "Yra šliuzas (Gateway)", + "overwrite-activity-time": "Perrašyti aktyvumo laiką prijungtam įrenginiui", + "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į", + "import": "Importuoti įrenginį", + "device-file": "Įrenginio failas", + "search": "Įrenginių paieška", + "selected-devices": "Pasirinkta { count, plural, =1 {1 įrenginys} other {# įrenginiai} }", + "device-configuration": "Įrenginio konfigūracija", + "transport-configuration": "Transporto konfigūracija", + "wizard": { + "device-details": "Informacija apie įrenginį" + }, + "unassign-devices-from-edge-title": "Ar tikrai norite atsieti { count, plural, =1 {1 įrenginį} other {# įrenginius} } nuo Edge?", + "unassign-devices-from-edge-text": "Po patvirtinimo visi pasirinkti įrenginiai bus atsieti ir nebebus pasiekiami per Edge.", + "time": "Laikas", + "connectivity": { + "check-connectivity": "Patikrinti ryšį", + "device-created-check-connectivity": "Įrenginys sukurtas. Patikrinkime ryšį!", + "loading-check-connectivity-command": "Įkeliamos ryšio tikrinimo komandos...", + "use-following-instructions": "Naudokite šias instrukcijas, kad išsiųstumėte telemetriją įrenginio vardu naudodami komandų eilutę (shell).", + "execute-following-command": "Vykdykite šią komandą", + "install-curl-windows": "Nuo Windows 10 versijos 17063, cURL įrankis prieinamas pagal numatymą.", + "install-curl-macos": "Nuo Mac OS X 10.2 (Jaguar) versijos, cURL įrankis prieinamas pagal numatymą.", + "install-mqtt-windows": "Naudokite instrukcijas, kad atsisiųstumėte, įdiegtumėte, sukonfigūruotumėte ir paleistumėte „mosquitto_pub“ įrankį.", + "install-coap-client": "Naudokite instrukcijas, kad atsisiųstumėte, įdiegtumėte, sukonfigūruotumėte ir paleistumėte „coap-client“ įrankį.", + "install-necessary-client-tools": "Įdiekite būtinus kliento įrankius", + "mqtts-x509-command": "Naudokite šią dokumentaciją, kad prijungtumėte įrenginį per MQTT su X.509 autorizacija.", + "coaps-x509-command": "Naudokite šią dokumentaciją, kad prijungtumėte įrenginį per CoAP (DTLS) su X.509 autorizacija.", + "snmp-command": "Naudokite šią dokumentaciją, kad prijungtumėte įrenginį per SNMP protokolą.", + "sparkplug-command": "Naudokite šią dokumentaciją, kad prijungtumėte įrenginį per MQTT Sparkplug protokolą.", + "lwm2m-command": "Naudokite šią dokumentaciją, kad prijungtumėte įrenginį per LwM2M protokolą." + } + }, + "dynamic-form": { + "property": { + "properties": "Savybės", + "property": "Savybė", + "id": "Id", + "name": "Pavadinimas", + "type": "Tipas", + "type-text": "Tekstas", + "type-password": "Slaptažodis", + "type-textarea": "Teksto sritis", + "type-number": "Skaičius", + "type-switch": "Perjungiklis", + "type-select": "Pasirinkimas", + "type-radios": "Žymimieji mygtukai", + "type-datetime": "Data / Laikas", + "type-image": "Paveikslėlis", + "type-javascript": "JavaScript", + "type-json": "JSON", + "type-html": "HTML", + "type-css": "CSS", + "type-markdown": "Markdown", + "type-color": "Spalva", + "type-color-settings": "Spalvų nustatymai", + "type-font": "Šriftas", + "type-units": "Vienetai", + "type-icon": "Piktograma", + "type-fieldset": "Lauko rinkinys (fieldset)", + "type-array": "Masyvas", + "type-html-section": "HTML sekcija", + "group-title": "Grupės pavadinimas", + "no-properties": "Savybės nesukonfigūruotos", + "add-property": "Pridėti savybę", + "property-settings": "Savybės nustatymai", + "remove-property": "Pašalinti savybę", + "default-value": "Numatytoji reikšmė", + "value-required": "Reikšmė būtina", + "number-settings": "Skaičiaus nustatymai", + "min": "Min", + "max": "Maks", + "step": "Žingsnis", + "selected-options-limit": "Pasirinktų parinkčių limitas", + "advanced-ui-settings": "Išplėstiniai UI nustatymai", + "disable-on-property": "Išjungti pagal savybę", + "disable-on-property-none": "Nėra (laukas visada įjungtas)", + "display-condition-function": "Rodymo sąlygos funkcija", + "sub-label": "Papildoma etiketė", + "vertical-divider-after": "Vertikalus skirtukas po elemento", + "input-field-suffix": "Įvesties lauko sufiksas", + "property-row-classes": "Savybės eilutės CSS klasės", + "property-field-classes": "Savybės lauko CSS klasės", + "not-unique-property-ids-error": "Savybių ID turi būti unikalūs!", + "enable-multiple-select": "Įjungti daugybinį pasirinkimą", + "allow-empty-select-option": "Leisti tuščią pasirinkimo reikšmę", + "select-options": "Pasirinkimo parinktys", + "not-unique-select-option-value-error": "Pasirinkimo reikšmės turi būti unikalios!", + "value": "Reikšmė", + "label": "Etiketė", + "add-option": "Pridėti parinktį", + "no-options": "Parinkčių nėra", + "remove-option": "Pašalinti parinktį", + "textarea-rows": "Teksto srities eilučių skaičius", + "help-id": "Pagalbos ID", + "buttons-direction": "Mygtukų išdėstymo kryptis", + "direction-row": "Eilutėje", + "direction-column": "Stulpelyje", + "radio-button-options": "Žymimųjų mygtukų parinktys", + "datetime-type": "Datos/laiko lauko tipas", + "datetime-type-date": "Data", + "datetime-type-time": "Laikas", + "datetime-type-datetime": "Data ir laikas", + "enable-clear-button": "Įjungti išvalymo mygtuką", + "html-section-settings": "HTML sekcijos nustatymai", + "html-section-classes": "HTML sekcijos CSS klasės", + "html-section-content": "HTML sekcijos turinys", + "array-item": "Masyvo elementas", + "item-type": "Elemento tipas", + "item-name": "Elemento pavadinimas", + "no-items": "Elementų nėra", + "support-unit-conversion": "Palaikyti vienetų konvertavimą" + }, + "clear-form": "Išvalyti formą", + "clear-form-prompt": "Ar tikrai norite pašalinti visas formos savybes?", + "import-form": "Importuoti formą iš JSON", + "export-form": "Eksportuoti formą į JSON", + "json-file": "JSON failas", + "json-content": "JSON turinys", + "invalid-form-json-file-error": "Nepavyko importuoti formos iš JSON: neteisinga formos JSON duomenų struktūra." + }, + "asset-profile": { + "asset-profile": "Turto profilis", + "asset-profiles": "Turto profiliai", + "all-asset-profiles": "Visi turto profiliai", + "add": "Pridėti turto profilį", + "edit": "Redaguoti turto profilį", + "asset-profile-details": "Turto profilio informacija", + "no-asset-profiles-text": "Turto profilių nerasta", + "search": "Ieškoti turto profilių", + "selected-asset-profiles": "{ count, plural, =1 {1 turto profilis} other {# turto profiliai} } pasirinkta", + "no-asset-profiles-matching": "Turto profilio, atitinkančio '{{entity}}', nerasta.", + "asset-profile-required": "Turto profilis būtinas.", + "idCopiedMessage": "Turto profilio ID nukopijuotas į iškarpinę", + "set-default": "Nustatyti kaip numatytąjį turto profilį", + "delete": "Pašalinti turto profilį", + "copyId": "Kopijuoti turto profilio ID", + "name-max-length": "Pavadinimas turi būti trumpesnis nei 256 simboliai.", + "new-device-profile-name": "Turto profilio pavadinimas", + "new-device-profile-name-required": "Turto profilio pavadinimas būtinas.", + "name": "Pavadinimas", + "name-required": "Pavadinimas būtinas.", + "image": "Turto profilio paveikslėlis", + "description": "Aprašymas", + "default": "Numatytasis", + "default-rule-chain": "Numatytoji taisyklių grandinė", + "default-edge-rule-chain": "Numatytoji Edge taisyklių grandinė", + "default-edge-rule-chain-hint": "Naudojama Edge aplinkoje apdoroti gaunamus duomenis turtui, kuris priklauso šiam profiliui.", + "mobile-dashboard": "Mobilusis skydelis", + "mobile-dashboard-hint": "Naudojamas mobiliojoje programėlėje kaip turto informacijos skydelis.", + "select-queue-hint": "Pasirinkite iš išskleidžiamojo sąrašo.", + "delete-asset-profile-title": "Ar tikrai norite pašalinti turto profilį '{{assetProfileName}}'?", + "delete-asset-profile-text": "Būkite atsargūs, po patvirtinimo turto profilis ir visi su juo susiję duomenys bus negrįžtamai pašalinti.", + "delete-asset-profiles-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 turto profilį} other {# turto profilius} }?", + "delete-asset-profiles-text": "Būkite atsargūs, po patvirtinimo visi pasirinkti turto profiliai ir susiję duomenys bus negrįžtamai pašalinti.", + "set-default-asset-profile-title": "Ar tikrai norite nustatyti turto profilį '{{assetProfileName}}' kaip numatytąjį?", + "set-default-asset-profile-text": "Po patvirtinimo šis turto profilis bus pažymėtas kaip numatytasis ir bus naudojamas naujiems turtams, kuriems nenurodytas profilis.", + "no-asset-profiles-found": "Turto profilių nerasta.", + "create-new-asset-profile": "Sukurti naują!", + "create-asset-profile": "Sukurti naują turto profilį", + "import": "Importuoti turto profilį", + "export": "Eksportuoti turto profilį", + "export-failed-error": "Nepavyko eksportuoti turto profilio: {{error}}", + "asset-profile-file": "Turto profilio failas", + "invalid-asset-profile-file-error": "Nepavyko importuoti turto profilio: neteisinga duomenų struktūra." + }, + "device-profile": { + "device-profile": "Įrenginio profilis", + "device-profiles": "Įrenginių profiliai", + "all-device-profiles": "Visi įrenginių profiliai", + "add": "Pridėti įrenginio profilį", + "edit": "Redaguoti įrenginio profilį", + "device-profile-details": "Įrenginio profilio informacija", + "no-device-profiles-text": "Įrenginių profilių nerasta", + "search": "Ieškoti įrenginių profilių", + "selected-device-profiles": "{ count, plural, =1 {1 įrenginio profilis} other {# įrenginių profiliai} } pasirinkta", + "no-device-profiles-matching": "Įrenginio profilio, atitinkančio '{{entity}}', nerasta.", + "device-profile-required": "Įrenginio profilis būtinas.", + "idCopiedMessage": "Įrenginio profilio ID nukopijuotas į iškarpinę", + "set-default": "Nustatyti kaip numatytąjį įrenginio profilį", + "delete": "Pašalinti įrenginio profilį", + "copyId": "Kopijuoti įrenginio profilio ID", + "name-max-length": "Pavadinimas turi būti trumpesnis nei 256 simboliai.", + "name": "Pavadinimas", + "name-required": "Pavadinimas būtinas.", + "type": "Profilio tipas", + "type-required": "Profilio tipas būtinas.", + "type-default": "Numatytasis", + "image": "Įrenginio profilio paveikslėlis", + "transport-type": "Perdavimo tipas", + "transport-type-required": "Perdavimo tipas būtinas.", + "transport-type-default": "Numatytasis", + "transport-type-default-hint": "Palaiko pagrindinius MQTT, HTTP ir CoAP perdavimo tipus.", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "Įjungia išplėstinius MQTT perdavimo nustatymus.", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "Įjungia išplėstinius CoAP perdavimo nustatymus.", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "LWM2M perdavimo tipas.", + "transport-type-snmp": "SNMP", + "transport-type-snmp-hint": "Nustatykite SNMP perdavimo konfigūraciją.", + "transport-type-http": "HTTP", + "description": "Aprašymas", + "default": "Numatytasis", + "profile-configuration": "Profilio konfigūracija", + "transport-configuration": "Perdavimo konfigūracija", + "default-rule-chain": "Numatytoji taisyklių grandinė", + "default-edge-rule-chain": "Numatytoji Edge taisyklių grandinė", + "default-edge-rule-chain-hint": "Naudojama Edge aplinkoje apdoroti duomenis, gaunamus iš įrenginių, naudojančių šį profilį.", + "mobile-dashboard": "Mobilusis skydelis", + "mobile-dashboard-hint": "Naudojamas mobiliojoje programėlėje kaip įrenginio informacijos skydelis.", + "select-queue-hint": "Pasirinkite iš išskleidžiamojo sąrašo.", + "delete-device-profile-title": "Ar tikrai norite pašalinti įrenginio profilį '{{deviceProfileName}}'?", + "delete-device-profile-text": "Būkite atsargūs, po patvirtinimo įrenginio profilis ir visi susiję duomenys, įskaitant OTA atnaujinimus, bus negrįžtamai pašalinti.", + "delete-device-profiles-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 įrenginio profilį} other {# įrenginių profilius} }?", + "delete-device-profiles-text": "Būkite atsargūs, po patvirtinimo visi pasirinkti įrenginių profiliai ir susiję duomenys, įskaitant OTA atnaujinimus, bus negrįžtamai pašalinti.", + "set-default-device-profile-title": "Ar tikrai norite nustatyti įrenginio profilį '{{deviceProfileName}}' kaip numatytąjį?", + "set-default-device-profile-text": "Po patvirtinimo šis įrenginio profilis bus pažymėtas kaip numatytasis ir bus naudojamas naujiems įrenginiams, kuriems profilis nenurodytas.", + "no-device-profiles-found": "Įrenginių profilių nerasta.", + "create-new-device-profile": "Sukurti naują!", + "mqtt-device-topic-filters": "MQTT įrenginių temų filtrai", + "mqtt-device-topic-filters-unique": "MQTT įrenginių temų filtrai turi būti unikalūs.", + "mqtt-device-topic-filters-spark-plug": "MQTT Sparkplug B Edge of Network (EoN) mazgas.", + "mqtt-device-topic-filters-spark-plug-hint": "Leidžia prisijungimus iš EoN mazgų, naudojančių Sparkplug B formatą ir temų struktūrą.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names": "SparkPlug metrikos, saugomos kaip atributai.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names-hint": "SparkPlug metrikų pavadinimai, kurie bus saugomi kaip įrenginio atributai. Visos kitos metrikos bus saugomos kaip telemetrijos duomenys.", + "mqtt-device-payload-type": "MQTT įrenginio duomenų formatas", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "mqtt-enable-compatibility-with-json-payload-format": "Įjungti suderinamumą su kitais duomenų formatais.", + "mqtt-enable-compatibility-with-json-payload-format-hint": "Įjungus šią parinktį, platforma pagal nutylėjimą naudos Protobuf formatą. Jei analizė nepavyks, bus naudojamas JSON. Naudinga pereinant nuo JSON prie Protobuf atnaujinant programinę įrangą. Rekomenduojama išjungti, kai visi įrenginiai bus atnaujinti.", + "mqtt-use-json-format-for-default-downlink-topics": "Naudoti JSON formatą numatytosioms atsiuntimo temoms", + "mqtt-use-json-format-for-default-downlink-topics-hint": "Kai įjungta, platforma naudos JSON formatą siųsdama atributus ir RPC per temas v1/devices/me/.... Tai neturi įtakos naujoms (v2) temoms.", + "mqtt-send-ack-on-validation-exception": "Siųsti PUBACK, kai PUBLISH žinutės validacija nesėkminga", + "mqtt-send-ack-on-validation-exception-hint": "Pagal numatymą platforma nutraukia MQTT sesiją, kai žinutės validacija nesėkminga. Įjungus šią parinktį, vietoj to bus siunčiamas patvirtinimas.", + "mqtt-protocol-version": "Protokolo versija", + "snmp-add-mapping": "Pridėti SNMP atvaizdavimą", + "snmp-mapping-not-configured": "OID atvaizdavimas į telemetriją/atributus nesukonfigūruotas", + "snmp-timseries-or-attribute-name": "Telemetrijos/atributo pavadinimas atvaizdavimui", + "snmp-timseries-or-attribute-type": "Telemetrijos/atributo tipas atvaizdavimui", + "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": "Duomenų formatas būtinas.", + "coap-device-type": "CoAP įrenginio tipas", + "coap-device-payload-type": "CoAP įrenginio duomenų formatas", + "coap-device-type-required": "CoAP įrenginio tipas būtinas.", + "coap-device-type-default": "Numatytasis", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "Palaikomi vieno lygmens [+] ir kelių lygių [#] pakaitos simboliai.", + "telemetry-topic-filter": "Telemetrijos temų filtras", + "telemetry-topic-filter-required": "Telemetrijos temų filtras būtinas.", + "attributes-topic-filter": "Atributų publikavimo temų filtras", + "attributes-subscribe-topic-filter": "Atributų prenumeratos temų filtras", + "attributes-topic-filter-required": "Atributų publikavimo temų filtras būtinas.", + "attributes-subscribe-topic-filter-required": "Atributų prenumeratos tema būtina.", + "telemetry-proto-schema": "Telemetrijos Protobuf schema", + "telemetry-proto-schema-required": "Telemetrijos Protobuf schema būtina.", + "attributes-proto-schema": "Atributų Protobuf schema", + "attributes-proto-schema-required": "Atributų Protobuf schema būtina.", + "rpc-response-proto-schema": "RPC atsakymo Protobuf schema", + "rpc-response-proto-schema-required": "RPC atsakymo Protobuf schema būtina.", + "rpc-response-topic-filter": "RPC atsakymo temų filtras", + "rpc-response-topic-filter-required": "RPC atsakymo temų filtras būtinas.", + "rpc-request-proto-schema": "RPC užklausos Protobuf schema", + "rpc-request-proto-schema-required": "RPC užklausos Protobuf schema būtina.", + "rpc-request-proto-schema-hint": "RPC užklausos žinutėje visada turi būti laukeliai: string method = 1; int32 requestId = 2; ir params = 3 (bet kokio tipo).", + "not-valid-pattern-topic-filter": "Netinkamas temų filtro šablonas.", + "not-valid-single-character": "Netinkamas vieno lygmens pakaitos simbolio naudojimas.", + "not-valid-multi-character": "Netinkamas kelių lygių pakaitos simbolio naudojimas.", + "single-level-wildcards-hint": "[+] galima naudoti bet kuriame temų filtro lygyje, pvz. v1/devices/+/telemetry.", + "multi-level-wildcards-hint": "[#] turi būti paskutinis simbolis, pvz. # arba v1/devices/me/#.", + "alarm-rules": "Įspėjimų taisyklės", + "alarm-rules-with-count": "Įspėjimų taisyklės ({{count}})", + "no-alarm-rules": "Įspėjimų taisyklių nesukonfigūruota.", + "add-alarm-rule": "Pridėti įspėjimo taisyklę", + "edit-alarm-rule": "Redaguoti įspėjimo taisyklę", + "alarm-type": "Įspėjimo tipas", + "alarm-type-required": "Įspėjimo tipas būtinas.", + "alarm-type-unique": "Įspėjimo tipas turi būti unikalus šiame profilyje.", + "alarm-type-max-length": "Įspėjimo tipo pavadinimas turi būti trumpesnis nei 256 simboliai.", + "create-alarm-pattern": "Sukurti {{alarmType}} įspėjimą", + "create-alarm-rules": "Sukurti įspėjimo taisykles", + "no-create-alarm-rules": "Sukūrimo sąlygų nėra.", + "add-create-alarm-rule-prompt": "Pridėkite įspėjimo sukūrimo sąlygą", + "clear-alarm-rule": "Išvalyti įspėjimo taisyklę", + "no-clear-alarm-rule": "Nėra išvalymo sąlygų", + "add-create-alarm-rule": "Pridėti sukūrimo sąlygą", + "add-clear-alarm-rule": "Pridėti išvalymo sąlygą", + "select-alarm-severity": "Pasirinkite įspėjimo rimtumą", + "alarm-severity-required": "Įspėjimo rimtumas būtinas.", + "condition-duration": "Sąlygos trukmė", + "condition-duration-value": "Trukmės reikšmė", + "condition-duration-time-unit": "Laiko vienetas", + "condition-duration-value-range": "Trukmės reikšmė turi būti nuo 1 iki 2147483647.", + "condition-duration-value-pattern": "Trukmės reikšmė turi būti sveikasis skaičius.", + "condition-duration-value-required": "Trukmės reikšmė būtina.", + "condition-duration-time-unit-required": "Laiko vienetas būtinas.", + "advanced-settings": "Išplėstiniai nustatymai", + "alarm-rule-additional-info": "Papildoma informacija", + "edit-alarm-rule-additional-info": "Redaguoti papildomą informaciją", + "alarm-rule-additional-info-placeholder": "Pateikite komentarus ar pastabas, kurios bus rodomos įspėjimo informacijos skiltyje „Papildoma informacija“.", + "alarm-rule-additional-info-hint": "Patarimas: naudokite ${keyName} norėdami įtraukti atributų ar telemetrijos reikšmes.", + "alarm-rule-mobile-dashboard": "Mobilusis įspėjimų skydelis", + "alarm-rule-mobile-dashboard-hint": "Naudojamas mobiliojoje programėlėje kaip įspėjimo detalių skydelis.", + "alarm-rule-no-mobile-dashboard": "Skydelis nepasirinktas.", + "propagate-alarm": "Platinti įspėjimą susijusiems subjektams", + "alarm-rule-relation-types-list": "Ryšių tipai, kuriems platinti įspėjimą", + "alarm-rule-relation-types-list-hint": "Jei nenurodyta, įspėjimai bus platinami be filtravimo pagal ryšių tipą.", + "propagate-alarm-to-owner": "Platinti įspėjimą subjekto savininkui (klientui ar nuomininkui)", + "propagate-alarm-to-tenant": "Platinti įspėjimą nuomininkui", + "alarm-rule-condition": "Įspėjimo sąlyga", + "enter-alarm-rule-condition-prompt": "Pridėkite įspėjimo sąlygą", + "edit-alarm-rule-condition": "Redaguoti įspėjimo sąlygą", + "device-provisioning": "Įrenginių paruošimas (provisioning)", + "provision-strategy": "Paruošimo strategija", + "provision-strategy-required": "Paruošimo strategija būtina.", + "provision-strategy-disabled": "Išjungta", + "provision-strategy-created-new": "Leisti kurti naujus įrenginius", + "provision-strategy-check-pre-provisioned": "Tikrinti iš anksto paruoštus įrenginius", + "provision-device-key": "Įrenginio paruošimo raktas", + "provision-device-key-required": "Įrenginio paruošimo raktas būtinas.", + "copy-provision-key": "Kopijuoti paruošimo raktą", + "provision-key-copied-message": "Paruošimo raktas nukopijuotas į iškarpinę.", + "provision-device-secret": "Įrenginio paruošimo slaptas kodas", + "provision-device-secret-required": "Įrenginio paruošimo slaptas kodas būtinas.", + "copy-provision-secret": "Kopijuoti slaptą kodą", + "provision-secret-copied-message": "Slaptas kodas nukopijuotas į iškarpinę.", + "provision-strategy-x509": { + "certificate-chain": "X509 sertifikatų grandinė", + "certificate-chain-hint": "X.509 strategija naudojama įrenginiams paruošti naudojant dvipusį TLS ryšį ir kliento sertifikatus.", + "allow-create-new-devices": "Leisti kurti naujus įrenginius", + "allow-create-new-devices-hint": "Jei pažymėta, nauji įrenginiai bus kuriami, o kliento sertifikatas naudojamas kaip įrenginio įgaliojimai.", + "certificate-value": "Sertifikatas PEM formatu", + "certificate-value-required": "Sertifikatas PEM formatu būtinas.", + "cn-regex-variable": "CN reguliariosios išraiškos kintamasis", + "cn-regex-variable-required": "CN reguliariosios išraiškos kintamasis būtinas.", + "cn-regex-variable-hint": "Naudojamas įrenginio pavadinimui gauti iš X509 sertifikato 'Common Name' lauko." + }, + "condition": "Sąlyga", + "condition-type": "Sąlygos tipas", + "condition-type-simple": "Paprasta", + "condition-type-duration": "Trukmės", + "condition-during": "Per {{during}}", + "condition-during-dynamic": "Per „{{attribute}}“ ({{during}})", + "condition-type-repeating": "Pasikartojanti", + "condition-type-required": "Sąlygos tipas būtinas.", + "condition-repeating-value": "Įvykių skaičius", + "condition-repeating-value-range": "Įvykių skaičius turi būti nuo 1 iki 2147483647.", + "condition-repeating-value-pattern": "Įvykių skaičius turi būti sveikasis skaičius.", + "condition-repeating-value-required": "Įvykių skaičius būtinas.", + "condition-repeat-times": "Kartojasi { count, plural, =1 {1 kartą} other {# kartus} }", + "condition-repeat-times-dynamic": "Kartojasi „{attribute}“ ({ count, plural, =1 {1 kartą} other {# kartus} })", + "schedule-type": "Tvarkaraščio tipas", + "schedule-type-required": "Tvarkaraščio tipas būtinas.", + "schedule": "Tvarkaraštis", + "edit-schedule": "Redaguoti įspėjimo tvarkaraštį", + "schedule-any-time": "Aktyvus visą laiką", + "schedule-specific-time": "Aktyvus tam tikru laiku", + "schedule-custom": "Pasirinktinis", + "schedule-day": { + "monday": "Pirmadienis", + "tuesday": "Antradienis", + "wednesday": "Trečiadienis", + "thursday": "Ketvirtadienis", + "friday": "Penktadienis", + "saturday": "Šeštadienis", + "sunday": "Sekmadienis" + }, + "schedule-days": "Dienos", + "schedule-time": "Laikas", + "schedule-time-from": "Nuo", + "schedule-time-to": "Iki", + "schedule-days-of-week-required": "Reikia pasirinkti bent vieną savaitės dieną.", + "create-device-profile": "Sukurti naują įrenginio profilį", + "import": "Importuoti įrenginio profilį", + "export": "Eksportuoti įrenginio profilį", + "export-failed-error": "Nepavyko eksportuoti įrenginio profilio: {{error}}", + "device-profile-file": "Įrenginio profilio failas", + "invalid-device-profile-file-error": "Nepavyko importuoti įrenginio profilio: neteisinga profilio duomenų struktūra.", + "power-saving-mode": "Energijos taupymo režimas", + "power-saving-mode-type": { + "default": "Naudoti įrenginio profilio energijos taupymo režimą", + "psm": "Power Saving Mode (PSM)", + "drx": "Discontinuous Reception (DRX)", + "edrx": "Extended Discontinuous Reception (eDRX)" + }, + "edrx-cycle": "eDRX ciklas", + "edrx-cycle-required": "eDRX ciklas būtinas.", + "edrx-cycle-pattern": "eDRX ciklas turi būti teigiamas sveikasis skaičius.", + "edrx-cycle-min": "Minimalus eDRX ciklo skaičius – {{ min }} sekundžių.", + "paging-transmission-window": "Puslapiavimo perdavimo langas", + "paging-transmission-window-required": "Puslapiavimo perdavimo langas būtinas.", + "paging-transmission-window-pattern": "Puslapiavimo perdavimo langas turi būti teigiamas sveikasis skaičius.", + "paging-transmission-window-min": "Minimalus puslapiavimo perdavimo langas – {{ min }} sekundžių.", + "psm-activity-timer": "PSM aktyvumo laikmatis", + "psm-activity-timer-required": "PSM aktyvumo laikmatis būtinas.", + "psm-activity-timer-pattern": "PSM aktyvumo laikmatis turi būti teigiamas sveikasis skaičius.", + "psm-activity-timer-min": "Minimalus PSM aktyvumo laikmatis – {{ min }} sekundžių.", + "lwm2m": { + "object-list": "Objektų sąrašas", + "object-list-empty": "Objektai nepasirinkti.", + "no-objects-found": "Objektų nerasta.", + "no-objects-matching": "Objektų, atitinkančių '{{object}}', nerasta.", + "model-tab": "LWM2M modelis", + "add-new-instances": "Pridėti naujus egzempliorius", + "instances-list": "Egzempliorių sąrašas", + "instances-list-required": "Egzempliorių sąrašas būtinas.", + "instance-id-pattern": "Egzemplioriaus ID turi būti teigiamas sveikasis skaičius.", + "instance-id-max": "Didžiausia egzemplioriaus ID reikšmė – {{max}}.", + "instance": "Egzempliorius", + "resource-label": "#ID Išteklius (Resource name)", + "observe-label": "Stebėjimas", + "attribute-label": "Atributas", + "telemetry-label": "Telemetrija", + "edit-observe-select": "Norėdami redaguoti stebėjimą, pasirinkite telemetriją arba atributą", + "edit-attributes-select": "Norėdami redaguoti atributus, pasirinkite telemetriją arba atributą", + "no-attributes-set": "Atributai nenustatyti", + "key-name": "Rakto pavadinimas", + "key-name-required": "Rakto pavadinimas būtinas.", + "attribute-name": "Atributo pavadinimas", + "attribute-name-required": "Atributo pavadinimas būtinas.", + "attribute-value": "Atributo reikšmė", + "attribute-value-required": "Atributo reikšmė būtina.", + "attribute-value-pattern": "Atributo reikšmė turi būti teigiamas sveikasis skaičius.", + "edit-attributes": "Redaguoti atributus: {{ name }}", + "view-attributes": "Peržiūrėti atributus: {{ name }}", + "add-attribute": "Pridėti atributą", + "edit-attribute": "Redaguoti atributą", + "view-attribute": "Peržiūrėti atributą", + "remove-attribute": "Pašalinti atributą", + "delete-server-text": "Būkite atsargūs – po patvirtinimo serverio konfigūracija bus negrįžtamai prarasta.", + "delete-server-title": "Ar tikrai norite pašalinti serverį?", + "mode": "Saugumo konfigūracijos režimas", + "bootstrap-tab": "Bootstrap", + "bootstrap-server-legend": "Bootstrap Server (Trumpasis ID...)", + "lwm2m-server-legend": "LwM2M Server (Trumpasis ID...)", + "server": "Serveris", + "short-id": "Trumpasis serverio ID", + "short-id-tooltip": "Trumpasis serverio ID naudojamas LwM2M serverio egzemplioriaus susiejimui.\nŠis identifikatorius unikalus kiekvienam LwM2M klientui.\nReikšmė privaloma, kai Bootstrap-Server parametras yra 'false'.\nVertės ID:0 ir ID:65535 negali būti naudojamos LwM2M serverio identifikavimui.", + "short-id-tooltip-bootstrap": "Trumpasis serverio ID naudojamas LwM2M serverio egzemplioriaus susiejimui.\nPrivaloma reikšmė, kai Bootstrap-Server parametras yra 'false'.", + "short-id-required": "Trumpasis serverio ID būtinas.", + "short-id-range": "Trumpasis serverio ID turi būti tarp {{ min }} ir {{ max }}.", + "short-id-pattern": "Trumpasis serverio ID turi būti teigiamas sveikasis skaičius.", + "lifetime": "Kliento registracijos trukmė", + "lifetime-required": "Kliento registracijos trukmė būtina.", + "lifetime-pattern": "Kliento registracijos trukmė turi būti teigiamas sveikasis skaičius.", + "default-min-period": "Minimalus laikotarpis tarp dviejų pranešimų (s)", + "default-min-period-tooltip": "Numatytoji vertė, kurią LwM2M klientas turėtų naudoti minimaliam stebėjimo laikotarpiui, jei parametras nenurodytas.", + "default-min-period-required": "Minimalus laikotarpis būtinas.", + "default-min-period-pattern": "Minimalus laikotarpis turi būti teigiamas sveikasis skaičius.", + "notification-storing": "Pranešimų saugojimas, kai įrenginys išjungtas arba neprisijungęs", + "binding": "Susiejimas (Binding)", + "binding-type": { + "u": "U: Klientas pasiekiamas per UDP ryšį bet kuriuo metu.", + "m": "M: Klientas pasiekiamas per MQTT ryšį bet kuriuo metu.", + "h": "H: Klientas pasiekiamas per HTTP ryšį bet kuriuo metu.", + "t": "T: Klientas pasiekiamas per TCP ryšį bet kuriuo metu.", + "s": "S: Klientas pasiekiamas per SMS ryšį bet kuriuo metu.", + "n": "N: Klientas turi siųsti atsakymą naudodamas Non-IP ryšį (palaikoma nuo LWM2M 1.1).", + "uq": "UQ: UDP ryšys eilės režimu (nepalaikoma nuo LWM2M 1.1).", + "uqs": "UQS: aktyvus tiek UDP, tiek SMS ryšys; UDP eilės režimu, SMS standartiniu režimu (nepalaikoma nuo LWM2M 1.1).", + "tq": "TQ: TCP ryšys eilės režimu (nepalaikoma nuo LWM2M 1.1).", + "tqs": "TQS: aktyvus tiek TCP, tiek SMS ryšys; TCP eilės režimu, SMS standartiniu režimu (nepalaikoma nuo LWM2M 1.1).", + "sq": "SQ: SMS ryšys eilės režimu (nepalaikoma nuo LWM2M 1.1)." + }, + "binding-tooltip": "Tai yra sąrašas iš LwM2M serverio objekto „binding“ ištekliaus - /1/x/7.\nNurodo palaikomus ryšio (binding) režimus LwM2M kliente.\nŠi reikšmė TURĖTŲ sutapti su „Supported Binding and Modes“ ištekliaus reikšme įrenginio objekte (/3/0/16).\nNors palaikomi keli transportavimo protokolai, visos sesijos metu galima naudoti tik vieną ryšio būdą.\nPavyzdžiui, kai palaikomi UDP ir SMS, LwM2M klientas ir serveris gali naudoti tik vieną — arba UDP, arba SMS — visos sesijos metu.", + "bootstrap-server": "Bootstrap serveris", + "lwm2m-server": "LwM2M serveris", + "include-bootstrap-server": "Įtraukti Bootstrap serverio atnaujinimus", + "bootstrap-update-title": "Bootstrap serveris jau sukonfigūruotas. Ar tikrai norite pašalinti atnaujinimus?", + "bootstrap-update-text": "Būkite atsargūs — po patvirtinimo Bootstrap serverio konfigūracija bus negrįžtamai prarasta.", + "server-host": "Serverio adresas (Host)", + "server-host-required": "Serverio adresas būtinas.", + "server-port": "Prievadas (Port)", + "server-port-required": "Prievadas būtinas.", + "server-port-pattern": "Prievadas turi būti teigiamas sveikasis skaičius.", + "server-port-range": "Prievadas turi būti tarp 1 ir 65535.", + "server-public-key": "Serverio viešasis raktas", + "server-public-key-required": "Serverio viešasis raktas būtinas.", + "client-hold-off-time": "Kliento atidėjimo laikas (Hold Off Time)", + "client-hold-off-time-required": "Kliento atidėjimo laikas būtinas.", + "client-hold-off-time-pattern": "Kliento atidėjimo laikas turi būti teigiamas sveikasis skaičius.", + "client-hold-off-time-tooltip": "Naudojamas tik su Bootstrap serveriu – nurodo laukimo laiką prieš registraciją.", + "account-after-timeout": "Sąskaita po pasibaigusio laiko (Account after timeout)", + "account-after-timeout-required": "„Account after timeout“ reikšmė būtina.", + "account-after-timeout-pattern": "„Account after timeout“ turi būti teigiamas sveikasis skaičius.", + "account-after-timeout-tooltip": "Bootstrap serverio „Account after timeout“ reikšmė, apibrėžianti veiksmą po nurodyto laiko.", + "server-type": "Serverio tipas", + "add-new-server-title": "Pridėti naują serverio konfigūraciją", + "add-server-config": "Pridėti serverio konfigūraciją", + "add-lwm2m-server-config": "Pridėti LwM2M serverį", + "no-config-servers": "Serverių konfigūracijų nėra", + "others-tab": "Kiti nustatymai", + "ota-update": "OTA atnaujinimas", + "use-object-19-for-ota-update": "Naudoti objektą 19 OTA failo metaduomenims (kontrolinė suma, dydis, versija, pavadinimas)", + "use-object-19-for-ota-update-hint": "Naudokite išteklių ObjectId = 19 OTA atnaujinimams: Firmware → InstanceId = 65534, Software → InstanceId = 65535. Duomenų formatas – JSON, užkoduotas Base64 formatu. JSON faile pateikiama OTA metainformacija: „Checksum“ (SHA256). Papildomi laukai: „Title“ (pavadinimas), „Version“ (versija), „File Name“ (failo pavadinimas), „File Size“ (dydis baitais).", + "client-strategy": "Kliento strategija jungiantis", + "client-strategy-label": "Strategija", + "client-strategy-only-observe": "Tik „Observe“ užklausos klientui po pradinio prisijungimo", + "client-strategy-read-all": "Perskaityti visus išteklius ir siųsti „Observe“ užklausas po registracijos", + "fw-update": "Programinės aparatinės įrangos (Firmware) atnaujinimas", + "fw-update-strategy": "Firmware atnaujinimo strategija", + "fw-update-strategy-data": "Įkelti firmware kaip dvejetainį failą naudojant objektą 19 ir išteklių 0 (Data)", + "fw-update-strategy-package": "Įkelti firmware kaip dvejetainį failą naudojant objektą 5 ir išteklių 0 (Package)", + "fw-update-strategy-package-uri": "Automatiškai sugeneruoti unikalų CoAP URL firmware parsisiuntimui ir naudoti objektą 5 bei išteklių 1 (Package URI)", + "sw-update": "Programinės įrangos (Software) atnaujinimas", + "sw-update-strategy": "Software atnaujinimo strategija", + "sw-update-strategy-package": "Įkelti dvejetainį failą naudojant objektą 9 ir išteklių 2 (Package)", + "sw-update-strategy-package-uri": "Automatiškai sugeneruoti unikalų CoAP URL programos parsisiuntimui ir naudoti objektą 9 bei išteklių 3 (Package URI)", + "fw-update-resource": "Firmware atnaujinimo CoAP išteklius", + "fw-update-resource-required": "Firmware atnaujinimo CoAP išteklius būtinas.", + "sw-update-resource": "Software atnaujinimo CoAP išteklius", + "sw-update-resource-required": "Software atnaujinimo CoAP išteklius būtinas.", + "config-json-tab": "JSON konfigūracijos profilis", + "attributes-name": { + "min-period": "Minimalus periodas", + "max-period": "Maksimalus periodas", + "greater-than": "Didesnis nei", + "less-than": "Mažesnis nei", + "step": "Žingsnis", + "min-evaluation-period": "Minimalus vertinimo periodas", + "max-evaluation-period": "Maksimalus vertinimo periodas" + }, + "default-object-id": "Numatytoji Objekto Versija (Atributas)", + "default-object-id-ver": { + "v1-0": "1.0", + "v1-1": "1.1", + "v1-2": "1.2" + }, + "observe-strategy": { + "observe-strategy": "Stebėjimo strategija", + "single": "Atskiras", + "single-description": "Vienas stebėjimo užklausimas vienam ištekliui (didesnis tikslumas, daugiau tinklo srauto)", + "composite-all": "Bendras (visi ištekliai)", + "composite-all-description": "Visi ištekliai stebimi naudojant vieną bendrą stebėjimo užklausą (efektyviau, bet mažiau lankstu)", + "composite-by-object": "Pagal objektus", + "composite-by-object-description": "Ištekliai grupuojami pagal objektų tipus ir stebimi naudojant atskiras bendras stebėjimo užklausas (subalansuotas metodas)" + } + }, + "snmp": { + "add-communication-config": "Pridėti ryšio konfigūraciją", + "add-mapping": "Pridėti susiejimą", + "authentication-passphrase": "Autentifikacijos slaptažodis", + "authentication-passphrase-required": "Autentifikacijos slaptažodis būtinas.", + "authentication-protocol": "Autentifikacijos protokolas", + "authentication-protocol-required": "Autentifikacijos protokolas būtinas.", + "communication-configs": "Ryšio konfigūracijos", + "community": "Bendruomenės eilutė (Community string)", + "community-required": "Bendruomenės eilutė būtina.", + "context-name": "Konteksto pavadinimas", + "data-key": "Duomenų raktas", + "data-key-required": "Duomenų raktas būtinas.", + "data-type": "Duomenų tipas", + "data-type-required": "Duomenų tipas būtinas.", + "engine-id": "Variklio ID (Engine ID)", + "host": "Serveris / įrenginys (Host)", + "host-required": "Serverio / įrenginio pavadinimas būtinas.", + "oid": "OID (Objekto identifikatorius)", + "oid-pattern": "Neteisingas OID formatas.", + "oid-required": "OID būtinas.", + "please-add-communication-config": "Pridėkite ryšio konfigūraciją.", + "please-add-mapping-config": "Pridėkite susiejimo konfigūraciją.", + "port": "Prievadas (Port)", + "port-format": "Neteisingas prievado formatas.", + "port-required": "Prievadas būtinas.", + "privacy-passphrase": "Privatumo slaptažodis", + "privacy-passphrase-required": "Privatumo slaptažodis būtinas.", + "privacy-protocol": "Privatumo protokolas", + "privacy-protocol-required": "Privatumo protokolas būtinas.", + "protocol-version": "Protokolo versija", + "protocol-version-required": "Protokolo versija būtina.", + "querying-frequency": "Užklausų dažnis, ms", + "querying-frequency-invalid-format": "Užklausų dažnis turi būti teigiamas sveikasis skaičius.", + "querying-frequency-required": "Užklausų dažnis būtinas.", + "retries": "Bandymų skaičius (Retries)", + "retries-invalid-format": "Bandymų skaičius turi būti teigiamas sveikasis skaičius.", + "retries-required": "Bandymų skaičius būtinas.", + "scope": "Apimtis (Scope)", + "scope-required": "Apimtis būtina.", + "security-name": "Saugos pavadinimas", + "security-name-required": "Saugos pavadinimas būtinas.", + "timeout-ms": "Laiko limitas, ms", + "timeout-ms-invalid-format": "Laiko limitas turi būti teigiamas sveikasis skaičius.", + "timeout-ms-required": "Laiko limitas būtinas.", + "user-name": "Vartotojo vardas", + "user-name-required": "Vartotojo vardas būtinas." + } + }, + "dialog": { + "close": "Uždaryti dialogo langą", + "error-message-title": "Klaidos pranešimas:", + "error-details-title": "Informacija apie klaidą" + }, + "direction": { + "column": "Stulpelis", + "row": "Eilutė" + }, + "edge": { + "edge": "Edge įrenginys", + "edge-instances": "Edge instancijos", + "instances": "Instancijos", + "edge-file": "Edge failas", + "name-max-length": "Pavadinimas negali viršyti 256 simbolių.", + "label-max-length": "Etiketė negali viršyti 256 simbolių.", + "type-max-length": "Tipas negali viršyti 256 simbolių.", + "management": "Edge valdymas", + "no-edges-matching": "Edge įrenginių, atitinkančių '{{entity}}', nerasta.", + "add": "Pridėti Edge įrenginį", + "no-edges-text": "Edge įrenginių nerasta.", + "edge-details": "Edge įrenginio informacija", + "add-edge-text": "Pridėti naują Edge įrenginį", + "delete": "Panaikinti Edge įrenginį", + "delete-edge-title": "Ar tikrai norite panaikinti Edge įrenginį '{{edgeName}}'?", + "delete-edge-text": "Po patvirtinimo Edge įrenginys ir visa su juo susijusi informacija bus negrįžtamai panaikinta.", + "delete-edges-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 Edge įrenginį} other {# Edge įrenginius} }?", + "delete-edges-text": "Po patvirtinimo visi pasirinkti Edge įrenginiai ir jų duomenys bus negrįžtamai panaikinti.", + "name": "Pavadinimas", + "name-starts-with": "Edge įrenginio pavadinimas prasideda", + "name-required": "Pavadinimas būtinas.", + "description": "Aprašymas", + "details": "Išsami informacija", + "events": "Įvykiai", + "copy-id": "Kopijuoti Edge ID", + "id-copied-message": "Edge ID nukopijuotas į iškarpinę", + "sync": "Sinchronizuoti Edge", + "edge-required": "Edge būtinas.", + "edge-type": "Edge tipas", + "edge-type-required": "Edge tipas būtinas.", + "event-action": "Įvykio veiksmas", + "entity-id": "Subjekto ID", + "select-edge-type": "Pasirinkite Edge tipą", + "assign-to-customer": "Priskirti klientui", + "assign-to-customer-text": "Pasirinkite klientą, kuriam norite priskirti Edge įrenginį (-ius)", + "assign-edge-to-customer": "Priskirti Edge įrenginį (-ius) klientui", + "assign-edge-to-customer-text": "Pasirinkite Edge įrenginius, kuriuos norite priskirti klientui", + "assignedToCustomer": "Priskirta klientui", + "edge-public": "Edge yra viešas", + "assigned-to-customer": "Priskirta: {{customerTitle}}", + "unassign-from-customer": "Atsieti nuo kliento", + "unassign-edge-title": "Ar tikrai norite atsieti Edge '{{edgeName}}'?", + "unassign-edge-text": "Po patvirtinimo Edge įrenginys bus atsietas ir nepasiekiamas klientui.", + "unassign-edges-title": "Ar tikrai norite atsieti { count, plural, =1 {1 Edge įrenginį} other {# Edge įrenginius} }?", + "unassign-edges-text": "Po patvirtinimo visi pasirinkti Edge įrenginiai bus atsieti nuo kliento.", + "make-public": "Padaryti Edge viešu", + "make-public-edge-title": "Ar tikrai norite Edge '{{edgeName}}' padaryti viešu?", + "make-public-edge-text": "Po patvirtinimo Edge ir visi jo duomenys bus prieinami kitiems vartotojams.", + "make-private": "Padaryti Edge privačiu", + "public": "Viešas", + "make-private-edge-title": "Ar tikrai norite Edge '{{edgeName}}' padaryti privačiu?", + "make-private-edge-text": "Po patvirtinimo Edge ir jo duomenys taps privatūs ir nepasiekiami kitiems vartotojams.", + "import": "Importuoti Edge", + "install-connect-instructions": "Diegimo ir prijungimo instrukcijos", + "install-connect-instructions-edge-created": "Edge sukurtas! Peržiūrėkite diegimo ir prijungimo instrukcijas.", + "loading-edge-instructions": "Įkeliamos Edge instrukcijos...", + "label": "Etiketė", + "load-entity-error": "Nepavyko įkelti duomenų. Subjektas buvo panaikintas.", + "assign-new-edge": "Priskirti naują Edge", + "unassign-from-edge": "Atsieti nuo Edge", + "edge-key": "Edge raktas", + "copy-edge-key": "Kopijuoti Edge raktą", + "edge-key-copied-message": "Edge raktas nukopijuotas į iškarpinę", + "edge-secret": "Edge slaptas raktas", + "copy-edge-secret": "Kopijuoti Edge slaptą raktą", + "edge-secret-copied-message": "Edge slaptas raktas nukopijuotas į iškarpinę", + "manage-assets": "Valdyti turtą (assets)", + "manage-devices": "Valdyti įrenginius", + "manage-entity-views": "Valdyti subjekto rodinius", + "manage-dashboards": "Valdyti skydelius", + "manage-rulechains": "Valdyti taisyklių grandines (rule chains)", + "assets": "Edge turtas", + "devices": "Edge įrenginiai", + "entity-views": "Edge subjekto rodiniai", + "dashboard": "Edge skydelis", + "dashboards": "Edge skydeliai", + "rulechain-templates": "Taisyklių grandinių šablonai", + "edge-rulechain-templates": "Edge taisyklių grandinių šablonai", + "rulechains": "Edge taisyklių grandinės", + "search": "Ieškoti Edge įrenginių", + "selected-edges": "Pasirinkta { count, plural, =1 {1 Edge įrenginys} other {# Edge įrenginiai} }", + "any-edge": "Bet kuris Edge įrenginys", + "no-edge-types-matching": "Edge tipų, atitinkančių '{{entitySubtype}}', nerasta.", + "edge-type-list-empty": "Edge tipai nepasirinkti.", + "edge-types": "Edge tipai", + "enter-edge-type": "Įveskite Edge tipą", + "deployed": "Įdiegtas", + "pending": "Laukia", + "downlinks": "Downlink ryšiai", + "no-downlinks-prompt": "Downlink ryšių nerasta.", + "sync-process-started-successfully": "Sinchronizacija sėkmingai pradėta!", + "missing-related-rule-chains-title": "Edge trūksta susijusių taisyklių grandinių", + "missing-related-rule-chains-text": "Edge priskirtos taisyklių grandinės naudoja mazgus, nukreipiančius į kitų grandinių žinutes, kurios nėra priskirtos šiam Edge.

    Trūkstamų grandinių sąrašas:
    {{missingRuleChains}}", + "widget-datasource-error": "Šis valdiklis palaiko tik EDGE subjekto duomenų šaltinius", + "upgrade-instructions": "Atnaujinimo instrukcijos.", + "connected": "Prisijungęs", + "disconnected": "Atsijungęs" + }, + "edge-event": { + "type-dashboard": "Skydelis", + "type-asset": "Turtas (Asset)", + "type-device": "Įrenginys", + "type-device-profile": "Įrenginio profilis", + "type-asset-profile": "Turto profilis", + "type-entity-view": "Subjekto rodinys", + "type-alarm": "Signalizacija (Alarm)", + "type-rule-chain": "Taisyklių grandinė (Rule Chain)", + "type-rule-chain-metadata": "Taisyklių grandinės metaduomenys", + "type-edge": "Edge įrenginys", + "type-user": "Vartotojas", + "type-tenant": "Nuomininkas (Tenant)", + "type-tenant-profile": "Nuomininko profilis", + "type-customer": "Klientas", + "type-relation": "Ryšys (Relation)", + "type-widgets-bundle": "Valdiklių paketas (Widgets Bundle)", + "type-widgets-type": "Valdiklio tipas (Widget Type)", + "type-admin-settings": "Administratoriaus nustatymai", + "type-ota-package": "OTA paketas (Over-The-Air)", + "type-queue": "Eilė (Queue)", + "action-type-added": "Pridėta", + "action-type-deleted": "Panaikinta", + "action-type-updated": "Atnaujinta", + "action-type-post-attributes": "Atributai įkelti", + "action-type-attributes-updated": "Atributai atnaujinti", + "action-type-attributes-deleted": "Atributai ištrinti", + "action-type-timeseries-updated": "Telemetrija atnaujinta", + "action-type-credentials-updated": "Įgaliojimai atnaujinti", + "action-type-assigned-to-customer": "Priskirta klientui", + "action-type-unassigned-from-customer": "Atsieta nuo kliento", + "action-type-relation-add-or-update": "Ryšys pridėtas arba atnaujintas", + "action-type-relation-deleted": "Ryšys panaikintas", + "action-type-rpc-call": "RPC iškvietimas", + "action-type-alarm-ack": "Signalizacija patvirtinta", + "action-type-alarm-clear": "Signalizacija išvalyta", + "action-type-alarm-assigned": "Signalizacija priskirta", + "action-type-alarm-unassigned": "Signalizacija atsieta", + "action-type-assigned-to-edge": "Priskirta Edge įrenginiui", + "action-type-unassigned-from-edge": "Atsieta nuo Edge įrenginio", + "action-type-credentials-request": "Įgaliojimų užklausa", + "action-type-entity-merge-request": "Subjektų sujungimo užklausa" + }, + "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 šiuose 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 pseudonimą '{{alias}}'", + "alias": "Pseudonimas", + "alias-required": "Subjekto pseudonimas būtinas.", + "remove-alias": "Pašalinti subjekto pseudonimą", + "add-alias": "Pridėti subjekto pseudonimą", + "edit-alias": "Redaguoti 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": "Įveskite subjekto tipą", + "no-entities-matching": "Subjektų, atitinkančių '{{entity}}', nėra.", + "no-entities-text": "Subjektų nerasta", + "no-entity-types-matching": "Subjektų tipų, atitinkančių '{{entityType}}', nėra.", + "name-starts-with": "Pavadinimas prasideda", + "help-text": "Naudokite simbolį '%' pagal paieškos poreikį: '%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": "Bent vienas subjekto tipas turi būti pasirinktas.", + "entity-name-filter-required": "Subjekto pavadinimo filtras būtinas.", + "entity-name-filter-no-entity-matched": "Subjektų, kurių pavadinimas prasideda '{{entity}}', nėra.", + "all-subtypes": "Visi", + "select-entities": "Pasirinkite subjektus", + "no-aliases-found": "Pseudonimų nėra.", + "no-alias-matching": "Pseudonimo '{{alias}}' nėra.", + "create-new-alias": "Sukurti naują!", + "create-new": "Sukurti naują", + "key": "Raktas", + "key-name": "Rakto pavadinimas", + "no-keys-found": "Raktų nėra.", + "no-key-matching": "Rakto '{{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": "Įrenginių profiliai", + "clear-selected-profiles": "Išvalyti pasirinktus profilius", + "list-of-device-profiles": "{ count, plural, =1 {Vienas įrenginio profilis} other {# įrenginių profilių sąrašas} }", + "device-profile-name-starts-with": "Įrenginių profiliai, kurių pavadinimai prasideda '{{prefix}}'", + "type-asset-profile": "Turto profilis", + "type-asset-profiles": "Turto profiliai", + "list-of-asset-profiles": "{ count, plural, =1 {Vienas turto profilis} other {# turto profilių sąrašas} }", + "asset-profile-name-starts-with": "Turto profiliai, kurių pavadinimai prasideda '{{prefix}}'", + "type-asset": "Turtas", + "type-assets": "Turtai", + "list-of-assets": "{ count, plural, =1 {Vienas turtas} other {# turto sąrašas} }", + "asset-name-starts-with": "Turtas, kurio pavadinimas prasideda '{{prefix}}'", + "type-entity-view": "Subjekto rodinys", + "type-entity-views": "Subjektų rodiniai", + "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": "Valdytojo profilis", + "type-tenant-profiles": "Valdytojo profiliai", + "list-of-tenant-profiles": "{ count, plural, =1 {Vienas valdytojo profilis} other {# valdytojo profilių sąrašas} }", + "tenant-profile-name-starts-with": "Valdytojo profiliai, kurių pavadinimai prasideda '{{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, kurių pavadinimai prasideda '{{prefix}}'", + "type-rulenode": "Taisyklės mazgas", + "type-rulenodes": "Taisyklių mazgai", + "list-of-rulenodes": "{ count, plural, =1 {Vienas taisyklės mazgas} other {# taisyklių mazgų sąrašas} }", + "rulenode-name-starts-with": "Taisyklių mazgai, 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-calculated-field": "Apskaičiuotas laukas", + "type-calculated-fields": "Apskaičiuoti laukai", + "type-ai-model": "DI modelis", + "type-ai-models": "DI modeliai", + "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": "Valdiklis", + "type-widgets": "Valdikliai", + "list-of-widgets": "{ count, plural, =1 {Vienas valdiklis} other {# valdiklių sąrašas} }", + "search": "Subjektų paieška", + "selected-entities": "Pasirinkta { count, plural, =1 {1 subjektas} other {# subjektai} }", + "entity-name": "Subjekto pavadinimas", + "entity-label": "Subjekto etiketė", + "details": "Informacija apie subjektą", + "no-entities-prompt": "Subjektų nėra", + "no-data": "Nėra duomenų atvaizdavimui", + "columns-to-display": "Rodomi stulpeliai", + "type-api-usage-state": "API naudojimo būsena", + "type-edge": "Edge", + "type-edges": "Edge įrenginiai", + "list-of-edges": "{ count, plural, =1 {Vienas Edge} other {# Edge sąrašas} }", + "edge-name-starts-with": "Edge, kurių pavadinimai prasideda '{{prefix}}'", + "version-conflict": { + "message": "Ar norite perrašyti esamą versiją, ar atmesti pakeitimus ir įkelti naujausią versiją?", + "link": "Savo {{entityType}} versiją galite atsisiųsti naudojantis šia", + "overwrite": "Perrašyti versiją", + "discard": "Atmesti pakeitimus" + }, + "type-tb-resource": "Išteklius", + "type-tb-resources": "Ištekliai", + "list-of-tb-resources": "{ count, plural, =1 {Vienas išteklius} other {# išteklių sąrašas} }", + "type-ota-package": "OTA paketas", + "type-ota-packages": "OTA paketai", + "list-of-ota-packages": "{ count, plural, =1 {Vienas OTA paketas} other {# OTA paketų sąrašas} }", + "type-rpc": "RPC", + "type-queue": "Eilė", + "type-queue-stats": "Eilės statistika", + "type-queues-stats": "Eilių statistika", + "type-notification": "Pranešimas", + "type-notification-rule": "Pranešimo taisyklė", + "type-notification-rules": "Pranešimų taisyklės", + "list-of-notification-rules": "{ count, plural, =1 {Viena pranešimo taisyklė} other {# pranešimų taisyklių sąrašas} }", + "type-notification-target": "Pranešimo gavėjas", + "type-notification-targets": "Pranešimų gavėjai", + "list-of-notification-targets": "{ count, plural, =1 {Vienas pranešimo gavėjas} other {# pranešimų gavėjų sąrašas} }", + "type-notification-request": "Pranešimo užklausa", + "type-notification-template": "Pranešimo šablonas", + "type-notification-templates": "Pranešimų šablonai", + "list-of-notification-templates": "{ count, plural, =1 {Vienas pranešimo šablonas} other {# pranešimų šablonų sąrašas} }", + "link": "nuoroda", + "type-oauth2-client": "OAuth 2.0 klientas", + "type-oauth2-clients": "OAuth 2.0 klientai", + "list-of-oauth2-clients": "{ count, plural, =1 {Vienas OAuth 2.0 klientas} other {# OAuth 2.0 klientų sąrašas} }", + "type-domain": "Domenas", + "type-domains": "Domenai", + "list-of-domains": "{ count, plural, =1 {Vienas domenas} other {# domenų sąrašas} }", + "type-mobile-app": "Mobilioji programa", + "type-mobile-apps": "Mobiliosios programos", + "list-of-mobile-apps": "{ count, plural, =1 {Viena mobilioji programa} other {# mobiliųjų programų sąrašas} }", + "type-mobile-app-bundle": "Mobiliųjų programų rinkinys", + "type-mobile-app-bundles": "Mobiliųjų programų rinkiniai", + "list-of-mobile-app-bundles": "{ count, plural, =1 {Vienas mobiliųjų programų rinkinys} other {# mobiliųjų programų rinkinių sąrašas} }" + }, + "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": "Adresas", + "address2": "Adresas 2", + "zip": "Pašto kodas", + "phone": "Telefonas", + "label": "Etiketė", + "queue-name": "Eilės pavadinimas", + "service-id": "Paslaugos ID", + "owner-name": "Savininko vardas", + "owner-type": "Savininko tipas" + }, + "entity-view": { + "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-ts": "Pradžios laikas", + "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ę \"kopijuoti į vaizdą\" taisyklę ir \"Pateikti atributus\" bei \"Atributai atnaujinti\" ž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.", + "search": "Subjektų rodinių paieška", + "selected-entity-views": "Pasirinkta { count, plural, =1 {1 subjektų rodinys} other {# subjektų rodiniai} }", + "assign-entity-view-to-edge": "Priskirti subjekto rodinį (-ius) „Edge“ sistemai", + "assign-entity-view-to-edge-text": "Pasirinkite subjekto rodinius, kuriuos norite priskirti „Edge“ sistemai", + "unassign-entity-view-from-edge-title": "Ar tikrai norite atsieti subjekto rodinį '{{entityViewName}}' nuo „Edge“?", + "unassign-entity-view-from-edge-text": "Po patvirtinimo subjekto rodinys bus atsietas ir nebebus pasiekiamas per „Edge“.", + "unassign-entity-views-from-edge-action-title": "Atsieti { count, plural, =1 {1 subjekto rodinį} other {# subjekto rodinius} } nuo „Edge“", + "unassign-entity-view-from-edge": "Atsieti subjekto rodinį", + "unassign-entity-views-from-edge-title": "Ar tikrai norite atsieti { count, plural, =1 {1 subjekto rodinį} other {# subjekto rodinius} }?", + "unassign-entity-views-from-edge-text": "Po patvirtinimo visi pasirinkti subjekto rodiniai bus atsieti ir nebebus pasiekiami per „Edge“." + }, + "event": { + "event-type": "Įvykio tipas", + "events-filter": "Įvykių filtras", + "clean-events": "Išvalyti įvykius", + "type-error": "Klaida", + "type-lc-event": "Gyvavimo ciklo įvykis", + "type-stats": "Statistika", + "type-debug-rule-node": "Derinimas (Rule Node)", + "type-debug-rule-chain": "Derinimas (Rule Chain)", + "type-debug-calculated-field": "Derinimas (Calculated Field)", + "arguments": "Argumentai", + "result": "Rezultatas", + "no-events-prompt": "Įvykių nėra", + "error": "Klaida", + "alarm": "Įspėjimas", + "event-time": "Įvykio laikas", + "server": "Serveris", + "body": "Turinys", + "method": "Metodas", + "type": "Tipas", + "metadata": "Metaduomenys", + "message": "Pranešimas", + "message-id": "Pranešimo ID", + "copy-message-id": "Kopijuoti pranešimo ID", + "message-type": "Pranešimo tipas", + "data-type": "Duomenų tipas", + "relation-type": "Ryšio tipas", + "data": "Duomenys", + "event": "Įvykis", + "status": "Būsena", + "success": "Sėkmingas", + "failed": "Nepavyko", + "messages-processed": "Apdoroti pranešimai", + "max-messages-processed": "Maks. apdorotų pranešimų", + "min-messages-processed": "Min. apdorotų pranešimų", + "errors-occurred": "Įvyko klaidų", + "max-errors-occurred": "Maks. klaidų", + "min-errors-occurred": "Min. klaidų", + "min-value": "Mažiausia leistina reikšmė yra 0.", + "all-events": "Visi", + "has-error": "Turi klaidą", + "entity-id": "Subjekto ID", + "copy-entity-id": "Kopijuoti subjekto ID", + "entity-type": "Subjekto tipas", + "clear-filter": "Išvalyti filtrą", + "clear-request-title": "Išvalyti visus įvykius", + "clear-request-text": "Ar tikrai norite išvalyti visus įvykius?", + "started": "Pradėta", + "updated": "Atnaujinta", + "stopped": "Sustabdyta" + }, + "extension": { + "extensions": "Plėtiniai", + "selected-extensions": "Pasirinkta { count, plural, =1 {1 plėtinys} other {# plėtiniai} }", + "type": "Tipas", + "key": "Raktas", + "value": "Reikšmė", + "id": "Id", + "extension-id": "Plėtinio ID", + "extension-type": "Plėtinio tipas", + "transformer-json": "JSON *", + "unique-id-required": "Dabartinis plėtinio ID jau egzistuoja.", + "delete": "Pašalinti plėtinį", + "add": "Pridėti plėtinį", + "edit": "Redaguoti plėtinį", + "delete-extension-title": "Ar tikrai norite pašalinti plėtinį '{{extensionId}}'?", + "delete-extension-text": "Būkite atsargūs — po patvirtinimo šis plėtinys ir visi susiję duomenys bus negrįžtamai pašalinti.", + "delete-extensions-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 plėtinį} other {# plėtinius} }?", + "delete-extensions-text": "Būkite atsargūs — po patvirtinimo visi pasirinkti plėtiniai bus pašalinti.", + "converters": "Keitikliai", + "converter-id": "Keitiklio ID", + "configuration": "Konfigūracija", + "converter-configurations": "Keitiklio konfigūracijos", + "token": "Saugos raktas", + "add-converter": "Pridėti keitiklį", + "add-config": "Pridėti keitiklio konfigūraciją", + "device-name-expression": "Įrenginio pavadinimo išraiška", + "device-type-expression": "Įrenginio tipo išraiška", + "custom": "Tinkintas", + "to-double": "Konvertuoti į Double", + "transformer": "Transformatorius", + "json-required": "Reikalingas transformatoriaus JSON.", + "json-parse": "Nepavyko apdoroti transformatoriaus JSON.", + "attributes": "Atributai", + "add-attribute": "Pridėti atributą", + "add-map": "Pridėti susiejimo elementą", + "timeseries": "Telemetrija", + "add-timeseries": "Pridėti telemetriją", + "field-required": "Laukas būtinas.", + "brokers": "Tarpiniai serveriai", + "add-broker": "Pridėti tarpinį serverį", + "host": "Serveris", + "port": "Prievadas", + "port-range": "Prievadas turi būti tarp 1 ir 65535.", + "ssl": "SSL", + "credentials": "Įgaliojimai", + "username": "Vartotojo vardas", + "password": "Slaptažodis", + "retry-interval": "Kartojimo intervalas (ms)", + "anonymous": "Anoniminis", + "basic": "Paprastas (Basic)", + "pem": "PEM", + "ca-cert": "CA sertifikato failas *", + "private-key": "Privataus rakto failas *", + "cert": "Sertifikato failas *", + "no-file": "Failas nepasirinktas.", + "drop-file": "Įmeskite failą arba spustelėkite, kad pasirinktumėte įkėlimui.", + "mapping": "Susiejimas", + "topic-filter": "Temos filtras", + "converter-type": "Keitiklio tipas", + "converter-json": "JSON", + "json-name-expression": "Įrenginio pavadinimo JSON išraiška", + "topic-name-expression": "Įrenginio pavadinimo temos išraiška", + "json-type-expression": "Įrenginio tipo JSON išraiška", + "topic-type-expression": "Įrenginio tipo temos išraiška", + "attribute-key-expression": "Atributo rakto išraiška", + "attr-json-key-expression": "Atributo rakto JSON išraiška", + "attr-topic-key-expression": "Atributo rakto temos išraiška", + "request-id-expression": "Užklausos ID išraiška", + "request-id-json-expression": "Užklausos ID JSON išraiška", + "request-id-topic-expression": "Užklausos ID temos išraiška", + "response-topic-expression": "Atsakymo temos išraiška", + "value-expression": "Reikšmės išraiška", + "topic": "Tema", + "timeout": "Laiko limitas (ms)", + "converter-json-required": "Keitiklio JSON yra būtinas.", + "converter-json-parse": "Nepavyko apdoroti keitiklio JSON.", + "filter-expression": "Filtro išraiška", + "connect-requests": "Prisijungimo užklausos", + "add-connect-request": "Pridėti prisijungimo užklausą", + "disconnect-requests": "Atsijungimo užklausos", + "add-disconnect-request": "Pridėti atsijungimo užklausą", + "attribute-requests": "Atributų užklausos", + "add-attribute-request": "Pridėti atributo užklausą", + "attribute-updates": "Atributų atnaujinimai", + "add-attribute-update": "Pridėti atributo atnaujinimą", + "server-side-rpc": "Serverio pusės RPC", + "add-server-side-rpc-request": "Pridėti serverio pusės RPC užklausą", + "device-name-filter": "Įrenginio pavadinimo filtras", + "attribute-filter": "Atributų filtras", + "method-filter": "Metodo filtras", + "request-topic-expression": "Užklausos temos išraiška", + "response-timeout": "Atsakymo laukimo limitas (ms)", + "topic-expression": "Temos išraiška", + "client-scope": "Kliento sritis", + "add-device": "Pridėti įrenginį", + "opc-server": "Serveriai", + "opc-add-server": "Pridėti serverį", + "opc-add-server-prompt": "Prašome pridėti serverį", + "opc-application-name": "Programos pavadinimas", + "opc-application-uri": "Programos URI", + "opc-scan-period-in-seconds": "Skenavimo periodas (s)", + "opc-security": "Saugumas", + "opc-identity": "Tapatybė", + "opc-keystore": "Raktinė (Keystore)", + "opc-type": "Tipas", + "opc-keystore-type": "Raktinės tipas", + "opc-keystore-location": "Vieta *", + "opc-keystore-password": "Slaptažodis", + "opc-keystore-alias": "Aliasas", + "opc-keystore-key-password": "Rakto slaptažodis", + "opc-device-node-pattern": "Įrenginio mazgo šablonas", + "opc-device-name-pattern": "Įrenginio pavadinimo šablonas", + "modbus-server": "Serveriai / vergai", + "modbus-add-server": "Pridėti serverį / vergą", + "modbus-add-server-prompt": "Prašome pridėti serverį / vergą", + "modbus-transport": "Transportas", + "modbus-tcp-reconnect": "Automatiškai prisijungti iš naujo", + "modbus-rtu-over-tcp": "RTU per TCP", + "modbus-port-name": "Serijinio prievado pavadinimas", + "modbus-encoding": "Kodavimas", + "modbus-parity": "Paritetas", + "modbus-baudrate": "Perdavimo greitis (baud)", + "modbus-databits": "Duomenų bitai", + "modbus-stopbits": "Stop bitai", + "modbus-databits-range": "Duomenų bitai turi būti nuo 7 iki 8.", + "modbus-stopbits-range": "Stop bitai turi būti nuo 1 iki 2.", + "modbus-unit-id": "Vieneto ID", + "modbus-unit-id-range": "Vieneto ID turi būti nuo 1 iki 247.", + "modbus-device-name": "Įrenginio pavadinimas", + "modbus-poll-period": "Užklausos periodas (ms)", + "modbus-attributes-poll-period": "Atributų užklausos periodas (ms)", + "modbus-timeseries-poll-period": "Telemetrijos užklausos periodas (ms)", + "modbus-poll-period-range": "Užklausos periodas turi būti teigiama reikšmė.", + "modbus-tag": "Žyma", + "modbus-function": "Funkcija", + "modbus-register-address": "Registrų adresas", + "modbus-register-address-range": "Registrų adresas turi būti nuo 0 iki 65535.", + "modbus-register-bit-index": "Bito indeksas", + "modbus-register-bit-index-range": "Bito indeksas turi būti nuo 0 iki 15.", + "modbus-register-count": "Registrų skaičius", + "modbus-register-count-range": "Registrų skaičius turi būti teigiama reikšmė.", + "modbus-byte-order": "Baitų tvarka", + "sync": { + "status": "Būsena", + "sync": "Sinchronizuota", + "not-sync": "Nesinchronizuota", + "last-sync-time": "Paskutinio sinchronizavimo laikas", + "not-available": "Nėra duomenų" + }, + "export-extensions-configuration": "Eksportuoti plėtinių konfigūraciją", + "import-extensions-configuration": "Importuoti plėtinių konfigūraciją", + "import-extensions": "Importuoti plėtinius", + "import-extension": "Importuoti plėtinį", + "export-extension": "Eksportuoti plėtinį", + "file": "Plėtinių failas", + "invalid-file-error": "Neteisingas plėtinio failas" + }, + "feature": { + "advanced-features": "Išplėstinės funkcijos" + }, + "filter": { + "add": "Pridėti filtrą", + "edit": "Redaguoti filtrą", + "name": "Filtro pavadinimas", + "name-required": "Filtro pavadinimas būtinas.", + "duplicate-filter": "Toks filtro pavadinimas jau egzistuoja.", + "filters": "Filtrai", + "unable-delete-filter-title": "Nepavyko ištrinti filtro", + "unable-delete-filter-text": "Filtras '{{filter}}' negali būti ištrintas, nes jis naudojamas šiuose valdikliuose:
    {{widgetsList}}", + "duplicate-filter-error": "Rastas pasikartojantis filtras '{{filter}}'.
    Filtrų pavadinimai skydelyje turi būti unikalūs.", + "missing-key-filters-error": "Trūksta pagrindinio filtro '{{filter}}'.", + "filter": "Filtras", + "editable": "Redaguojamas", + "editable-hint": "Leisti vartotojui pakeisti filtro reikšmę skydelyje.", + "no-filters-found": "Filtrų nerasta.", + "no-filter-text": "Filtras nenurodytas", + "add-filter-prompt": "Pridėkite filtrą", + "no-filter-matching": "Filtras '{{filter}}' nerastas.", + "create-new-filter": "Sukurti naują!", + "create-new": "Sukurti naują", + "filter-required": "Filtras būtinas.", + "operation": { + "operation": "Operacija", + "equal": "lygu", + "not-equal": "nelygu", + "starts-with": "prasideda nuo", + "ends-with": "baigiasi", + "contains": "turi reikšmę", + "not-contains": "neturi reikšmės", + "greater": "didesnis nei", + "less": "mažesnis nei", + "greater-or-equal": "didesnis arba lygus", + "less-or-equal": "mažesnis arba lygus", + "and": "ir", + "or": "arba", + "in": "yra sąraše", + "not-in": "nėra sąraše" + }, + "ignore-case": "Nepaisyti raidžių dydžio", + "value": "Reikšmė", + "remove-filter": "Pašalinti filtrą", + "duplicate-filter-action": "Dubliuoti filtrą", + "preview": "Filtro peržiūra", + "no-filters": "Filtrai nesukonfigūruoti", + "add-filter": "Pridėti filtrą", + "add-complex-filter": "Pridėti sudėtingą filtrą", + "add-complex": "Pridėti sudėtingą", + "complex-filter": "Sudėtingas filtras", + "edit-complex-filter": "Redaguoti sudėtingą filtrą", + "edit-filter-user-params": "Redaguoti filtro naudotojo parametrus", + "filter-user-params": "Filtro naudotojo parametrai", + "user-parameters": "Naudotojo parametrai", + "display-label": "Rodoma etiketė", + "custom-label": "Pasirinktinė etiketė", + "custom-label-hint": "Įjunkite, jei norite nustatyti savo etiketę filtrui. Išjungus, etiketė bus sugeneruota automatiškai.", + "order-priority": "Lauko tvarkos prioritetas", + "key-filter": "Raktinis filtras", + "key-filters": "Raktiniai filtrai", + "key-name": "Rakto pavadinimas", + "key-name-required": "Rakto pavadinimas būtinas.", + "key-type": { + "key-type": "Rakto tipas", + "attribute": "Atributas", + "timeseries": "Telemetrija", + "entity-field": "Subjekto laukas", + "constant": "Konstanta", + "client-attribute": "Kliento atributas", + "server-attribute": "Serverio atributas", + "shared-attribute": "Bendrinamas atributas" + }, + "value-type": { + "value-type": "Reikšmės tipas", + "string": "Tekstas", + "numeric": "Skaitinė", + "boolean": "Loginė (taip/ne)", + "date-time": "Data/laikas" + }, + "value-type-required": "Rakto reikšmės tipas būtinas.", + "key-value-type-change-title": "Ar tikrai norite pakeisti rakto reikšmės tipą?", + "key-value-type-change-message": "Patvirtinus naują reikšmės tipą, visi įvesti rakto filtrai bus pašalinti.", + "no-key-filters": "Rakto filtrai nesukonfigūruoti", + "add-key-filter": "Pridėti rakto filtrą", + "remove-key-filter": "Pašalinti rakto filtrą", + "edit-key-filter": "Redaguoti rakto filtrą", + "date": "Data", + "time": "Laikas", + "current-tenant": "Dabartinis valdytojas", + "current-customer": "Dabartinis klientas", + "current-user": "Dabartinis vartotojas", + "current-device": "Dabartinis įrenginys", + "default-value": "Numatytoji reikšmė", + "default-comma-separated-values": "Numatytosios reikšmės, atskirtos kableliais", + "dynamic-source-type": "Dinaminio šaltinio tipas", + "dynamic-value": "Dinaminė reikšmė", + "no-dynamic-value": "Nėra dinaminės reikšmės", + "source-attribute": "Šaltinio atributas", + "switch-to-dynamic-value": "Perjungti į dinaminę reikšmę", + "switch-to-default-value": "Perjungti į numatytąją reikšmę", + "inherit-owner": "Paveldėti iš savininko", + "source-attribute-not-set": "Jei šaltinio atributas nenustatytas", + "unit": "Vienetas" + }, + "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": { + "gateway-name": "Šliuzo pavadinimas", + "gateway-name-required": "Šliuzo pavadinimas būtinas.", + "gateways": "Šliuzai", + "create-new-gateway": "Sukurti naują šliuzą", + "create-new-gateway-text": "Ar tikrai norite sukurti naują šliuzą pavadinimu: '{{gatewayName}}'?", + "launch-command": "Paleidimo komanda", + "no-gateway-found": "Šliuzų nerasta.", + "no-gateway-matching": "'{{item}}' nerasta." + }, + "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": { + "gallery": "Paveikslėlių galerija", + "search": "Ieškoti paveikslėlio", + "selected-images": "{ count, plural, =1 {1 paveikslėlis} other {# paveikslėliai} } pasirinkta", + "created-time": "Sukūrimo laikas", + "name": "Pavadinimas", + "name-required": "Pavadinimas būtinas.", + "resolution": "Raiška", + "size": "Dydis", + "system": "Sistema", + "download-image": "Atsisiųsti paveikslėlį", + "export-image": "Eksportuoti paveikslėlį į JSON", + "import-image": "Importuoti paveikslėlį iš JSON", + "upload-image": "Įkelti paveikslėlį", + "edit-image": "Redaguoti paveikslėlį", + "image-details": "Paveikslėlio informacija", + "no-images": "Paveikslėlių nerasta", + "delete-image": "Pašalinti paveikslėlį", + "delete-image-title": "Ar tikrai norite pašalinti paveikslėlį '{{imageTitle}}'?", + "delete-image-text": "Būkite dėmesingi — po patvirtinimo paveikslėlio atkurti nebus įmanoma.", + "delete-images-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 paveikslėlį} other {# paveikslėlius} }?", + "delete-images-text": "Būkite dėmesingi — po patvirtinimo visi pasirinkti paveikslėliai bus pašalinti, o susiję duomenys taps negrąžinami.", + "list-mode": "Sąrašo rodinys", + "grid-mode": "Tinklelio rodinys", + "image-preview": "Paveikslėlio peržiūra", + "update-image": "Atnaujinti paveikslėlį", + "export-failed-error": "Nepavyko eksportuoti paveikslėlio: {{error}}", + "image-json-file": "Paveikslėlio JSON failas", + "invalid-image-json-file-error": "Nepavyko importuoti paveikslėlio iš JSON: neteisinga JSON duomenų struktūra.", + "image-is-in-use": "Paveikslėlis naudojamas kituose subjektuose", + "images-are-in-use": "Paveikslėliai naudojami kituose subjektuose", + "image-is-in-use-text": "Paveikslėlis '{{title}}' nebuvo ištrintas, nes jis naudojamas šiuose subjektuose:", + "images-are-in-use-text": "Ne visi paveikslėliai buvo ištrinti, nes jie naudojami kituose subjektuose.
    Galite peržiūrėti susijusius subjektus spustelėję Nuorodos mygtuką atitinkamoje eilutėje.
    Jei vis tiek norite ištrinti šiuos paveikslėlius, pasirinkite juos ir spustelėkite Ištrinti pasirinktus.", + "delete-image-in-use-text": "Jei vis tiek norite ištrinti paveikslėlį, spustelėkite Ištrinti vis tiek.", + "system-entities": "Sistemos subjektai:", + "entities": "Subjektai:", + "references": "Nuorodos", + "include-system-images": "Įtraukti sistemos paveikslėlius", + "clear-image": "Išvalyti paveikslėlį", + "no-image": "Nėra paveikslėlio", + "no-image-selected": "Nepasirinktas paveikslėlis", + "browse-from-gallery": "Pasirinkti iš galerijos", + "set-link": "Nustatyti nuorodą", + "image-link": "Paveikslėlio nuoroda", + "link": "Nuoroda", + "copy-image-link": "Kopijuoti paveikslėlio nuorodą", + "embed-image": "Įterpti paveikslėlį", + "embed-to-html": "Įterpti į HTML", + "embed-to-html-hint": "Ši funkcija leis nuorodą pasiekti neautorizuotiems naudotojams.", + "embed-to-html-text": "Naudodami šį kodo fragmentą galite įterpti paveikslėlį į HTML pagrįstus komponentus.
    Tokie komponentai apima HTML kortelių valdiklius, langelių turinio funkcijas ir pan.", + "embed-to-angular-template": "Įterpti į Angular HTML šabloną", + "embed-to-angular-template-text": "Naudodami šį kodo fragmentą galite įterpti paveikslėlį į Angular HTML šabloną, naudojamą komponentams.
    Tokie komponentai apima Markdown valdiklį, HTML sekciją valdiklio redaktoriuje, pasirinktus veiksmus ir pan." + }, + "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-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 kliento ID", + "user-name": "MQTT vartotojo vardas", + "password": "MQTT slaptažodis" + }, + "lwm2m": { + "client-endpoint": "LwM2M kliento galinio taško pavadinimas", + "security-config-mode": "LwM2M saugumo konfigūracijos režimas", + "client-identity": "LwM2M kliento identifikatorius", + "client-key": "LwM2M kliento raktas", + "client-cert": "LwM2M kliento viešasis raktas", + "bootstrap-server-security-mode": "LwM2M pradinio serverio (bootstrap) saugumo režimas", + "bootstrap-server-secret-key": "LwM2M pradinio serverio slaptasis raktas", + "bootstrap-server-public-key-id": "LwM2M pradinio serverio viešasis raktas arba ID", + "lwm2m-server-security-mode": "LwM2M serverio saugumo režimas", + "lwm2m-server-secret-key": "LwM2M serverio slaptasis raktas", + "lwm2m-server-public-key-id": "LwM2M serverio viešasis raktas arba ID" + }, + "snmp": { + "host": "SNMP serveris (hostas)", + "port": "SNMP prievadas (portas)", + "version": "SNMP versija (v1, v2c arba v3)", + "community-string": "SNMP bendruomenės eilutė" + }, + "isgateway": "Yra šliuzas", + "activity-time-from-gateway-device": "Aktyvumo laikas iš šliuzo įrenginio", + "description": "Aprašymas", + "routing-key": "Šliuzo raktas", + "secret": "Šliuzo slaptas raktas" + }, + "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ų." + } + }, + "scada": { + "symbols": "SCADA simboliai", + "search": "Ieškoti simbolio", + "selected-symbols": "{ count, plural, =1 {1 simbolis} other {# simboliai} } pasirinkta", + "download-symbol": "Atsisiųsti SCADA simbolį", + "export-symbol": "Eksportuoti SCADA simbolį į JSON", + "import-symbol": "Importuoti SCADA simbolį iš JSON", + "upload-symbol": "Įkelti SCADA simbolį", + "update-symbol": "Atnaujinti SCADA simbolį", + "edit-symbol": "Redaguoti SCADA simbolį", + "symbol-details": "SCADA simbolio informacija", + "mode-svg": "SVG", + "mode-xml": "XML", + "no-symbols": "Simbolių nerasta", + "show-hidden-elements": "Rodyti paslėptus elementus", + "hide-hidden-elements": "Slėpti paslėptus elementus", + "delete-symbol": "Ištrinti SCADA simbolį", + "delete-symbol-title": "Ar tikrai norite ištrinti SCADA simbolį '{{imageTitle}}'?", + "delete-symbol-text": "Būkite atsargūs — po patvirtinimo SCADA simbolis bus negrįžtamai pašalintas.", + "delete-symbols-title": "Ar tikrai norite ištrinti { count, plural, =1 {1 SCADA simbolį} other {# SCADA simbolius} }?", + "delete-symbols-text": "Būkite atsargūs — po patvirtinimo visi pasirinkti SCADA simboliai ir susiję duomenys bus negrįžtamai pašalinti.", + "include-system-symbols": "Įtraukti sistemos simbolius", + "symbol-preview": "Simbolio peržiūra", + "general": "Bendra informacija", + "tags": "Žymos", + "properties": "Savybės", + "title": "Pavadinimas", + "description": "Aprašymas", + "search-tags": "Ieškoti žymų", + "widget-size": "Valdiklio dydis", + "cols": "stulpeliai", + "rows": "eilutės", + "state-render-function": "Būsenos atvaizdavimo funkcija", + "preview": "Peržiūra", + "preview-widget-action-text": "Valdiklio veiksmas '{{type}}' sėkmingai įvykdytas!", + "no-symbol": "Nėra SCADA simbolio", + "no-symbol-selected": "Nepasirinktas SCADA simbolis", + "clear-symbol": "Išvalyti SCADA simbolį", + "browse-symbol-from-gallery": "Pasirinkti SCADA simbolį iš galerijos", + "zoom-in": "Priartinti", + "zoom-out": "Atitolinti", + "create-widget": "Sukurti valdiklį", + "create-widget-from-symbol": "Sukurti valdiklį iš SCADA simbolio", + "hidden": "paslėpta", + "tag": { + "tag": "Žyma", + "on-click-action": "Veiksmas paspaudus", + "no-tags": "Žymos nesukonfigūruotos", + "delete-tag-text": "Ar tikrai norite pašalinti žymą
    {{tag}} iš {{elementType}} elemento?", + "update-tag": "Atnaujinti žymą", + "enter-tag": "Įveskite žymą", + "tag-settings": "Žymos nustatymai", + "remove-tag": "Pašalinti žymą", + "add-tag": "Pridėti žymą" + }, + "behavior": { + "behavior": "Elgsena", + "id": "ID", + "name": "Pavadinimas", + "type": "Tipas", + "no-behaviors": "Elgsenos nesukonfigūruotos", + "add-behavior": "Pridėti elgseną", + "type-action": "Veiksmas", + "type-value": "Reikšmė", + "type-widget-action": "Valdiklio veiksmas", + "behavior-settings": "Elgsenos nustatymai", + "remove-behavior": "Pašalinti elgseną", + "hint": "Patarimas", + "group-title": "Grupės pavadinimas", + "value-type": "Reikšmės tipas", + "default-value": "Numatytoji reikšmė", + "true-label": "Tiesa etiketė", + "false-label": "Netiesa etiketė", + "state-label": "Būsenos etiketė", + "default-payload": "Numatytasis krovinys", + "not-unique-behavior-ids-error": "Elgsenos ID turi būti unikalūs!", + "default-settings": "Numatytieji nustatymai" + }, + "symbol": { + "symbol": "SCADA simbolis", + "fluid-presence": "Skysčio buvimas", + "fluid-presence-hint": "Nurodo, ar vamzdyje yra skysčio.", + "fluid-present": "Skystis yra", + "present": "Yra", + "absent": "Nėra", + "flow-presence": "Srauto buvimas", + "flow-presence-hint": "Nurodo, ar vamzdyje teka skystis.", + "flow-present": "Srautas yra", + "flow-direction": "Srauto kryptis", + "flow-direction-hint": "Nurodo skysčio tekėjimo kryptį.", + "forward": "Pirmyn", + "reverse": "Atgal", + "flow-animation-speed": "Srauto animacijos greitis", + "flow-animation-speed-hint": "Reikšmė, nurodanti animacijos greitį. 1 – normalus greitis, 0 – be animacijos, < 1 – lėtesnė, > 1 – greitesnė animacija.", + "leak": "Nuotėkis", + "leak-hint": "Nurodo, ar yra nuotėkis vamzdyje.", + "leak-present": "Nuotėkis yra", + "fluid-color": "Skysčio spalva", + "pipe-color": "Vamzdžio spalva", + "horizontal-pipe": "Horizontalus vamzdis", + "vertical-pipe": "Vertikalus vamzdis", + "horizontal-fluid-color": "Horizontalaus skysčio spalva", + "vertical-fluid-color": "Vertikalaus skysčio spalva", + "left-pipe": "Kairysis vamzdis", + "right-pipe": "Dešinysis vamzdis", + "top-pipe": "Viršutinis vamzdis", + "bottom-pipe": "Apatinis vamzdis", + "left-fluid-color": "Kairiojo skysčio spalva", + "right-fluid-color": "Dešiniojo skysčio spalva", + "top-fluid-color": "Viršutinio skysčio spalva", + "bottom-fluid-color": "Apatinio skysčio spalva", + "display": "Ekranas", + "display-format": "Ekrano formatas", + "value": "Reikšmė", + "decimals": "Skaitmenys po kablelio", + "units": "Vienetai", + "flow-meter-value-hint": "Reikšmė rodoma srauto matuoklyje", + "value-hint": "Reikšmė, nurodanti dabartinį dydį", + "running": "Veikia", + "running-hint": "Nurodo, ar komponentas veikia.", + "warning-state": "Įspėjimo būsena", + "warning": "Įspėjimas", + "warning-click": "Įspėjimo paspaudimas", + "warning-state-hint": "Nurodo, ar komponentas yra įspėjimo būsenoje.", + "critical-state": "Kritinė būsena", + "critical": "Kritinis", + "critical-click": "Kritinio paspaudimas", + "critical-state-hint": "Nurodo, ar komponentas yra kritinėje būsenoje.", + "critical-state-animation": "Kritinės būsenos animacija", + "critical-state-animation-hint": "Įjungia mirksėjimo animaciją, kai komponentas yra kritinėje būsenoje.", + "warning-critical-state-animation": "Įspėjimo/kritinės būsenos animacija", + "warning-critical-state-animation-hint": "Įjungia mirksėjimo animaciją, kai komponentas yra įspėjimo ar kritinėje būsenoje.", + "animation": "Animacija", + "broken": "Sugedęs", + "broken-hint": "Nurodo, ar komponentas sugedęs.", + "on-display-click": "Paspaudus ekraną", + "on-display-click-hint": "Veiksmas, atliekamas paspaudus ekraną.", + "pipe": "Vamzdis", + "default-border-color": "Numatytoji rėmelio spalva", + "active-border-color": "Aktyvaus rėmelio spalva", + "warning-border-color": "Įspėjimo rėmelio spalva", + "critical-border-color": "Kritinio rėmelio spalva", + "background-color": "Fono spalva", + "rotation-animation-speed": "Sukimosi animacijos greitis", + "rotation-animation-speed-hint": "Reikšmė, nurodanti sukimosi animacijos greitį. 1 – normalus, 0 – be animacijos, < 1 – lėtesnė, > 1 – greitesnė animacija.", + "on-click": "Paspaudus", + "on-click-hint": "Veiksmas, atliekamas paspaudus komponentą.", + "connectors-positions": "Jungčių pozicijos", + "right-connector": "Dešinė jungtis", + "right-top-connector": "Dešinysis viršutinis jungtuvas.", + "right-bottom-connector": "Dešinysis apatinis jungtuvas.", + "left-connector": "Kairė jungtis", + "left-top-connector": "Kairysis viršutinis jungtuvas.", + "left-bottom-connector": "Kairysis apatinis jungtuvas.", + "top-left-connector": "Kairysis viršutinis jungtuvas.", + "top-right-connector": "Dešinysis viršutinis jungtuvas.", + "top-connector": "Viršutinė jungtis", + "bottom-connector": "Apatinė jungtis", + "running-color": "Veikimo spalva", + "stopped-color": "Sustojimo spalva", + "stopped": "Sustojęs", + "warning-color": "Įspėjimo spalva", + "critical-color": "Kritinė spalva", + "opened": "Atidaryta", + "opened-hint": "Nurodo, ar komponentas atidarytas.", + "open": "Atidaryti", + "open-hint": "Veiksmas, atliekamas vartotojui spustelėjus, kad atidarytų komponentą.", + "close": "Uždaryti", + "close-hint": "Veiksmas, atliekamas vartotojui spustelėjus komponento uždarymą.", + "close-state-animation": "Uždarytos būsenos animacija.", + "close-state-animation-hint": "Ar įjungti mirksinčią animaciją, kai komponentas yra uždarytos būsenos.", + "opened-color": "Atidarytos būsenos spalva.", + "closed-color": "Uždarytos būsenos spalva.", + "opened-rotation-angle": "Atidarytos būsenos pasukimo kampas.", + "closed-rotation-angle": "Uždarytos būsenos pasukimo kampas.", + "tank-capacity": "Talpyklos talpa", + "tank-capacity-hint": "Slankiojo kablelio reikšmė, nurodanti bendrą bako talpą.", + "current-volume": "Dabartinis tūris", + "current-volume-hint": "Slankiojo kablelio reikšmė, nurodanti šiuo metu užimtą tūrį.", + "tank-color": "Talpyklos spalva", + "value-box": "Reikšmės langelis.", + "value-text": "Reikšmės tekstas.", + "scale": "Skalė", + "transparent-mode": "Permatomas režimas.", + "major-ticks": "Pagrindiniai žymekliai.", + "intervals": "Intervalai.", + "major-ticks-color": "Pagrindinių žymeklių spalva.", + "normal": "Normalu", + "minor-ticks": "Smulkieji žymekliai.", + "minor-ticks-color": "Smulkiųjų žymeklių spalva.", + "temperature": "Temperatūra", + "temperature-hint": "Slankiojo kablelio reikšmė, nurodanti dabartinę temperatūrą.", + "update-temperature": "Atnaujinti temperatūrą.", + "update-temperature-hint": "Veiksmas, atliekamas vartotojui spustelėjus, kad pakeistų dabartinę temperatūrą.", + "run": "Paleisti", + "run-hint": "Veiksmas, atliekamas vartotojui spustelėjus, kad paleistų komponentą.", + "stop": "Sustabdyti", + "stop-hint": "Veiksmas, atliekamas vartotojui spustelėjus, kad sustabdytų komponentą.", + "temperature-step": "Temperatūros žingsnio padidinimas.", + "heat-pump-color": "Šilumos siurblio spalva.", + "power-button-background": "Maitinimo mygtuko fonas.", + "value-box-background": "Value box background", + "value-units": "Reikšmės vienetai.", + "enable-units-scale": "Įjungti vienetus skalėje.", + "filtration-mode": "Filtravimo režimas.", + "filtration-mode-hint": "Sveikasis skaičius, nurodantis dabartinį filtravimo režimą.", + "filtration-mode-update": "Filtravimo režimo atnaujinimo būsena.", + "filtration-mode-update-hint": "Veiksmas, atliekamas vartotojui spustelėjus, kad pakeistų dabartinį filtravimo režimą.", + "filter-mode": "Filtras.", + "waste-mode": "Atliekos.", + "backwash-mode": "Atbulinis plovimas.", + "recirculate-mode": "Recirkuliacija.", + "rinse-mode": "Skalavimas.", + "closed-mode": "Uždaryta.", + "sand-filter-color": "Smėlio filtro spalva.", + "mode-box-background": "Režimo langelio fonas.", + "border-color": "Kraštinės spalva.", + "label-color": "Etiketės spalva", + "water-leak-hint": "Nurodo, ar yra nutekėjimas.", + "default-color": "Numatytoji spalva.", + "leak-color": "Nutekėjimo spalva.", + "full-value": "Pilna reikšmė.", + "full-value-hint": "Slankiojo kablelio reikšmė, nurodanti pilną reikšmę.", + "label": "Etiketė", + "icon": "Ikona", + "button-color": "Mygtuko spalva.", + "on-label": "„Įjungta“ žymės tekstas.", + "off-label": "„Išjungta“ žymės tekstas.", + "arrow-presence": "Rodyklės buvimas.", + "arrow-presence-hint": "Nurodo, ar jungtyje yra rodyklė.", + "arrow-present": "Rodyklė yra.", + "arrow-direction": "Srauto kryptis.", + "arrow-direction-hint": "Nurodo srauto kryptį.", + "flow-animation": "Srauto buvimas.", + "flow-animation-hint": "Nurodo, ar jungtyje teka skystis.", + "flow": "Srautas.", + "flow-line": "Linija.", + "flow-line-style": "Linijos stilius.", + "flow-style-hint": "Nustatykite brūkšnio ir tarpo reikšmes taip, kad jų suma būtų daloma iš 100 be liekanos, kad animacija būtų idealiai sinchronizuota.", + "flow-dash-cap": "Brūkšnio galas.", + "dash-cap-butt": "Galinė briauna.", + "dash-cap-round": "Apvalus.", + "dash-cap-square": "Kvadratinis.", + "dash": "Brūkšnys.", + "gap": "Tarpas.", + "main-line": "Pagrindinė linija.", + "line": "Linija", + "line-color": "Linijos spalva", + "arrow-color": "Rodyklės spalva.", + "target-value": "Tikslo reikšmė.", + "target-value-hint": "Nurodo tikslo tašką skalėje.", + "min-max-value": "Minimali ir maksimali reikšmė.", + "min-value": "Min.", + "max-value": "Maks.", + "progress-bar": "Eigos juosta.", + "progress-arrow": "Eigos rodyklė.", + "warning-scale-color": "Įspėjimo skalės spalva.", + "critical-scale-color": "Kritinės skalės spalva.", + "scale-color": "Skalės spalva.", + "target": "Tikslas.", + "high-warning-state": "Aukšto įspėjimo būsena.", + "show-high-warning-scale": "Rodyti aukšto įspėjimo skalės reikšmę.", + "high-warning-scale": "Aukšto įspėjimo skalė.", + "high-warning-state-hint": "Slankiojo kablelio reikšmė, nurodanti aukšto įspėjimo diapazoną iki aukštos kritinės arba didžiausios reikšmės.", + "low-warning-state": "Žemo įspėjimo būsena.", + "show-low-warning-scale": "Rodyti žemo įspėjimo skalės reikšmę.", + "low-warning-scale": "Žemo įspėjimo skalė.", + "low-warning-state-hint": "Slankiojo kablelio reikšmė, nurodanti žemo įspėjimo diapazoną iki žemos kritinės arba minimalios reikšmės.", + "high-critical-state": "Aukšta kritinė būsena.", + "show-high-critical-scale": "Rodyti aukštos kritinės skalės reikšmę.", + "high-critical-scale": "Aukšta kritinė skalė.", + "high-critical-state-hint": "Slankiojo kablelio reikšmė, nurodanti aukštą kritinį diapazoną iki didžiausios skalės reikšmės.", + "low-critical-state": "Žemos kritinės būsenos reikšmė.", + "show-low-critical-scale": "Rodyti žemos kritinės būsenos reikšmę.", + "low-critical-scale": "Žema kritinė būsena.", + "low-critical-state-hint": "Slankiojo kablelio reikšmė, nurodanti žemos kritinės reikšmės diapazoną iki minimalios skalės reikšmės.", + "filter-color": "Filtro spalva", + "colors": "Spalvos", + "indicator-colors": "Indikatorių spalvos", + "enabled": "Įjungta", + "disabled": "Išjungta", + "on": "Įjungta", + "off": "Išjungta", + "on-off-state": "Įjungimo/Išjungimo būsena.", + "on-off-state-hint": "Nurodo, ar komponentas yra įjungtos, ar išjungtos būsenos.", + "on-update-state": "Įjungimo atnaujinimo būsena.", + "on-update-state-hint": "Veiksmas, atliekamas vartotojui spustelėjus, kad atnaujintų būseną į „Įjungta“.", + "off-update-state": "Išjungimo atnaujinimo būsena.", + "off-update-state-hint": "Veiksmas, atliekamas vartotojui spustelėjus, kad atnaujintų būseną į „Išjungta“.", + "voltage": "Įtampa", + "input-voltage": "Įėjimo įtampa.", + "input-voltage-hint": "Slankiojo kablelio reikšmė, nurodanti įėjimo įtampos dydį.", + "output-voltage": "Išėjimo įtampa.", + "output-voltage-hint": "Slankiojo kablelio reikšmė, nurodanti išėjimo įtampos dydį.", + "first-phase-voltage": "Pirmosios fazės įtampa.", + "second-phase-voltage": "Antrosios fazės įtampa.", + "third-phase-voltage": "Trečiosios fazės įtampa.", + "phase-voltage-hint": "Slankiojo kablelio reikšmė, nurodanti dabartinės fazės įtampos dydį.", + "voltage-hint": "Slankiojo kablelio reikšmė, nurodanti dabartinę įtampą.", + "current-voltage-color": "Dabartinės įtampos spalva.", + "phase-indicator-color": "Fazės indikatoriaus spalva.", + "measured": "Išmatuota.", + "measured-hint": "Slankiojo kablelio reikšmė, nurodanti energijos suvartojimą kilovatvalandėmis.", + "day-rate": "Dienos tarifas.", + "night-rate": "Naktinis tarifas.", + "off-peak-rate": "Ne piko tarifas.", + "peak-rate": "Piko tarifas.", + "export-rate": "Eksporto tarifas.", + "operating-mode": "Veikimo režimas.", + "bypass-mode": "Apeiti.", + "operating-mode-hint": "Sveikasis skaičius, nurodantis dabartinį veikimo režimą (0 – IŠJUNGTA, 1 – ĮJUNGTA, 2 – APEINIMAS).", + "connected": "Prijungta", + "connected-hint": "Nurodo, ar komponentas yra prijungtos būsenos.", + "disconnected": "Atjungta", + "indicator": "Indikatorius", + "operation-mode": "Veikimo režimas", + "operation-mode-hint": "Nurodo, ar keitiklis veikia tinklo (Mains), ar inverterio (Inverter) režimu.", + "operation-mode-indicators-color": "Veikimo režimo indikatorių spalva.", + "mains-on-mode": "Tinklas įjungtas.", + "inverter-on-mode": "Keitiklis įjungtas.", + "charging-mode": "Įkrovimo režimas", + "charging-mode-hint": "Sveikasis skaičius, nurodantis dabartinį įkrovimo režimą (1 – Greitas įkrovimas, 2 – Sugėrimas, 3 – Palaikomasis įkrovimas).", + "charging-mode-indicators-color": "Įkrovimo režimo indikatorių spalva.", + "inverter-faults": "Gedimai.", + "inverter-fault-indicators-color": "Gedimo indikatorių spalva.", + "overload-fault": "Perkrova", + "overload-fault-hint": "Nurodo, ar keitiklis yra perkrovos būsenoje.", + "low-battery-fault": "Silpna baterija", + "low-battery-fault-hint": "Nurodo, ar akumuliatorius yra per daug išsikrovęs.", + "temperature-fault": "Temperatūra", + "temperature-fault-hint": "Nurodo, ar keitiklyje yra aukšta temperatūra.", + "triangle": "Trikampis.", + "socket": "Lizdas.", + "left-button": "Kairysis mygtukas.", + "right-button": "Dešinysis mygtukas.", + "alarm-colors": "Aliarmo spalvos", + "hook-color": "Kabliuko spalva" + } + }, + "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": "Sutvarkyti", + "mini": "Sumažinti", + "modules": "Moduliai", + "remove-module": "Pašalinti modulį", + "no-modules": "Modulių nesukonfigūruota", + "add-module": "Pridėti modulį", + "module-alias": "Pseudonimas", + "invalid-module-alias-name": "Neteisingas pseudonimo pavadinimas", + "module-resource": "JS modulio šaltinis", + "not-unique-module-aliases-error": "Modulių pseudonimai turi būti unikalūs!", + "show-module-info": "Rodyti modulio informaciją", + "show-module-source-code": "Rodyti modulio pirminį kodą", + "module-members": "Modulio nariai", + "module-no-members": "Modulis neturi eksportuotų narių", + "module-load-error": "Modulio įkėlimo klaida", + "source-code": "Pirminis kodas", + "source-code-load-error": "Pirminio kodo įkėlimo klaida", + "no-js-module-text": "JS modulių nerasta", + "no-js-module-matching": "Nerasta JS modulių, atitinkančių '{{module}}'." + }, + "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", + "add-new-breakpoint": "Pridėti naują pertraukos tašką", + "breakpoint": "Pertraukos taškas", + "breakpoints": "Pertraukos taškai", + "copy-from": "Kopijuoti iš", + "size": "Dydis", + "delete-breakpoint-title": "Ar tikrai norite ištrinti pertraukos tašką '{{name}}'?", + "delete-breakpoint-text": "Atkreipkite dėmesį, kad po patvirtinimo pertraukos taškas bus negrįžtamai pašalintas, o nustatymai bus atkurti pagal numatytąjį pertraukos tašką." + }, + "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", + "Min": "Min", + "Max": "Max", + "Avg": "Vid.", + "Total": "Viso", + "Latest": "Vėliausia", + "comparison-time-ago": { + "previousInterval": "(ankstesnis intervalas)", + "customInterval": "(pasirinktinis intervalas)", + "days": "(prieš dieną)", + "weeks": "(prieš savaitę)", + "months": "(prieš mėnesį)", + "years": "(prieš metus)" + }, + "column-title": "Stulpelio pavadinimas", + "label": "Etiketė", + "value": "Reikšmė" + }, + "login": { + "login": "Prisijungti", + "request-password-reset": "Prašyti slaptažodžio atstatymo", + "reset-password": "Atstatyti slaptažodį", + "create-password": "Sukurti slaptažodį", + "two-factor-authentication": "Dviejų veiksnių autentifikavimas", + "passwords-mismatch-error": "Įvesti slaptažodžiai turi sutapti!", + "password-again": "Pakartokite slaptažodį", + "sign-in": "Prisijungti", + "username": "Vartotojo 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žodžio 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", + "invalid-email-format": "Neteisingas el. pašto formatas.", + "login-with": "Prisijungti kaip {{name}}", + "or": "arba", + "error": "Prisijungimo klaida", + "verify-your-identity": "Patvirtinkite savo tapatybę", + "select-way-to-verify": "Pasirinkite tapatybės patvirtinimo būdą", + "resend-code": "Siųsti kodą iš naujo", + "resend-code-wait": "Galima siųsti iš naujo po { time, plural, =1 {1 sekundės} other {# sekundžių} }", + "try-another-way": "Bandykite kitu būdu", + "totp-auth-description": "Įveskite saugos kodą iš autentifikavimo programėlės.", + "totp-auth-placeholder": "Kodas", + "sms-auth-description": "Saugos kodas išsiųstas į jūsų telefoną adresu {{contact}}.", + "sms-auth-placeholder": "SMS kodas", + "email-auth-description": "Saugos kodas išsiųstas į jūsų el. paštą adresu {{contact}}.", + "email-auth-placeholder": "El. pašto kodas", + "backup-code-auth-description": "Įveskite vieną iš atsarginių kodų.", + "backup-code-auth-placeholder": "Atsarginis kodas", + "activation-link-expired": "Aktyvavimo nuorodos galiojimas baigėsi", + "activation-link-expired-message": "Profilio aktyvavimo nuoroda nebegalioja. Galite grįžti į prisijungimo puslapį ir gauti naują el. laišką.", + "reset-password-link-expired": "Slaptažodžio atstatymo nuoroda nebegalioja", + "reset-password-link-expired-message": "Slaptažodžio atstatymo nuoroda nebegalioja. Galite grįžti į prisijungimo puslapį ir gauti naują el. laišką." + }, + "mobile": { + "add-application": "Pridėti programėlę", + "app-id": "Programėlės svetainės asociacijos ID", + "app-id-required": "Programėlės svetainės asociacijos ID yra būtinas", + "app-id-pattern": "Neteisingas programėlės svetainės asociacijos ID formatas", + "app-store-link": "App Store nuoroda", + "app-store-link-required": "App Store nuoroda yra būtina", + "application-details": "Programėlės informacija", + "application-package": "Programėlės paketas", + "application-secret": "Programėlės slaptas raktas", + "application-secret-required": "Programėlės slaptas raktas yra būtinas", + "application": "Programėlė", + "applications": "Programėlės", + "copy-app-id": "Kopijuoti programėlės ID", + "copy-app-store-link": "Kopijuoti App Store nuorodą", + "copy-application-package": "Kopijuoti programėlės paketą", + "copy-application-secret": "Kopijuoti programėlės slaptą raktą", + "copy-google-play-link": "Kopijuoti Google Play nuorodą", + "copy-sha256-certificate-fingerprints": "Kopijuoti SHA256 sertifikato atspaudus", + "delete-application": "Ištrinti programėlę", + "delete-application-button-text": "Suprantu pasekmes, ištrinti programėlę", + "delete-application-text": "Šio veiksmo atšaukti nebus galima. Tai visam laikui ištrins jūsų programėlę.
    Jei nenorite jos ištrinti visam laikui, galite laikinai ją sustabdyti.
    Norėdami vis tiek ištrinti programėlę, įveskite patvirtinimo frazę \"{{phrase}}\".", + "delete-application-title-short": "Ar tikrai norite ištrinti programėlę '{{name}}'?", + "delete-application-text-short": "Būkite atsargūs – po patvirtinimo programėlė ir visi susiję duomenys bus negrįžtamai pašalinti.", + "delete-application-phrase": "ištrinti programėlę", + "delete-applications-bundle-text": "Būkite atsargūs – po patvirtinimo mobilusis paketas ir visi susiję duomenys bus negrįžtamai pašalinti.", + "delete-applications-bundle-title": "Ar tikrai norite ištrinti mobilųjį paketą '{{bundleName}}'?", + "generate-application-secret": "Generuoti programėlės slaptą raktą", + "google-play-link": "Google Play nuoroda", + "google-play-link-required": "Google Play nuoroda yra būtina", + "latest-version": "Naujausia versija", + "min-version": "Minimali versija", + "invalid-version-pattern": "Neteisingas versijos formatas. Naudokite formatą: major.minor.patch (pvz., 1.0.0).", + "mobile-center": "Mobilusis centras", + "mobile-package": "Programėlės paketas", + "mobile-package-max-length": "Programėlės pavadinimas turi būti trumpesnis nei 256 simboliai", + "mobile-package-required": "Programėlės paketas yra būtinas", + "mobile-package-pattern": "Neteisingas programėlės paketo formatas", + "mobile-package-title": "Programėlės pavadinimas", + "mobile-package-title-max-length": "Programėlės pavadinimas turi būti trumpesnis nei 256 simboliai", + "no-application": "Programėlių nerasta", + "no-bundles": "Paketų nerasta", + "platform-type": "Platformos tipas", + "search-application": "Ieškoti programėlių", + "search-bundles": "Ieškoti paketų", + "set": "Nustatyti", + "sha256-certificate-fingerprints": "SHA256 sertifikato atspaudai", + "sha256-certificate-fingerprints-required": "SHA256 sertifikato atspaudai yra būtini", + "sha256-certificate-fingerprints-pattern": "Neteisingas SHA256 sertifikato atspaudo formatas", + "show-hidden-pages": "Rodyti paslėptus puslapius", + "status": "Būsena", + "status-type": { + "deprecated": "Nebenaudojama", + "draft": "Juodraštis", + "published": "Paskelbta", + "suspended": "Sustabdyta" + }, + "store-information": "Parduotuvės informacija", + "version-information": "Versijos informacija", + "min-version-release-notes": "Minimalios versijos išleidimo pastabos", + "latest-version-release-notes": "Naujausios versijos išleidimo pastabos", + "bundle": "Paketas", + "bundles": "Paketai", + "add-bundle": "Pridėti paketą", + "title": "Pavadinimas", + "title-required": "Pavadinimas būtinas", + "title-cannot-contain-only-spaces": "Pavadinimas negali būti tik iš tarpų", + "title-max-length": "Pavadinimas turi būti trumpesnis nei 256 simboliai", + "oauth-clients": "OAuth 2.0 klientai", + "android-app": "Android programa", + "android-application": "Android aplikacija", + "ios-app": "iOS programa", + "ios-application": "iOS aplikacija", + "invalid-store-link": "Neteisinga parduotuvės nuoroda", + "enable-oauth": "Įjungti OAuth 2.0", + "enable-self-registration": "Įjungti savarankišką registraciją", + "edit-bundle": "Redaguoti paketą", + "description": "Aprašymas", + "basic-settings": "Pagrindiniai nustatymai", + "no-application-matching": "Nerasta programų, atitinkančių '{{entity}}'.", + "no-bundle-matching": "Nerasta paketų, atitinkančių '{{entity}}'.", + "application-required": "Programa būtina.", + "bundle-required": "Paketas būtinas.", + "no-application-text": "Programų nerasta", + "no-bundle-text": "Paketų nerasta", + "layout": "Išdėstymas", + "pages": "Puslapiai", + "hide-all-pages": "Slėpti visus puslapius", + "reset-to-default-pages": "Atstatyti numatytuosius puslapius", + "add-specific-page": "Pridėti konkretų puslapį", + "visible": "Matomas", + "hidden": "Paslėptas", + "reset-to-page-default": "Atstatyti puslapį į numatytąją būseną", + "mobile-599": "Mobilus (maks. 599px)", + "tablet-959": "Planšetė (maks. 959px)", + "max-element-number": "Maksimalus elementų skaičius", + "page-name": "Puslapio pavadinimas", + "page-name-required": "Puslapio pavadinimas būtinas.", + "page-name-cannot-contain-only-spaces": "Puslapio pavadinimas negali būti tik iš tarpų.", + "page-name-max-length": "Puslapio pavadinimas turi būti trumpesnis nei 256 simboliai", + "page-type": "Puslapio tipas", + "pages-types": { + "dashboard": "Valdymo skydas", + "web-view": "Žiniatinklio peržiūra", + "custom": "Pasirinktinis" + }, + "url": "URL", + "invalid-url-format": "Neteisingas URL formatas", + "path": "Kelias", + "invalid-path-format": "Neteisingas kelio formatas", + "custom-page": "Pasirinktinis puslapis", + "edit-page": "Redaguoti puslapį", + "edit-custom-page": "Redaguoti pasirinktą puslapį", + "delete-page": "Ištrinti puslapį", + "qr-code-widget": "QR kodo valdiklis", + "type-here": "Įveskite čia", + "configuration-dialog": "Konfigūracijos dialogas", + "configuration-app": "Konfigūracijos programa", + "configuration-step": { + "prepare-environment-title": "Paruošti kūrimo aplinką", + "prepare-environment-text": "Flutter ThingsBoard mobiliajai programai reikalingas Flutter SDK. Vadovaukitės instrukcijomis, kad nustatytumėte Flutter SDK.", + "get-source-code-title": "Gauti programos išeities kodą", + "get-source-code-text": "Galite gauti Flutter ThingsBoard mobiliosios programos išeities kodą, klonuodami jį iš GitHub saugyklos:", + "configure-app-settings-title": "Sukonfigūruoti programos nustatymus", + "configure-app-settings-text": "Atsisiųskite konfigūracijos failą ir įdėkite jį į projekto, kurį klonavote ankstesniame žingsnyje, šakninį katalogą.", + "download-file": "Atsisiųsti failą", + "run-app-title": "Paleisti programą", + "run-app-text": "Paleiskite programą kaip aprašyta jūsų IDE.\nJei naudojate terminalą, paleiskite programą naudodami šią komandą:", + "more-information": "Išsami informacija pateikta mūsų „Pradžios vadove“.", + "getting-started": "Pradžios vadovas" + } + }, + "notification": { + "action-button": "Veiksmo mygtukas", + "action-type": "Veiksmo tipas", + "active": "Aktyvus", + "add-notification-recipients-group": "Pridėti pranešimų gavėjų grupę", + "add-notification-template": "Pridėti pranešimo šabloną", + "add-recipient": "Pridėti gavėją", + "add-recipients": "Pridėti gavėjus", + "add-rule": "Pridėti taisyklę", + "add-stage": "Pridėti etapą", + "add-template": "Pridėti šabloną", + "after": "Po", + "alarm-assignment-trigger-settings": "Aliarmo priskyrimo trigerio nustatymai", + "alarm-comment-trigger-settings": "Aliarmo komentaro trigerio nustatymai", + "alarm-trigger-settings": "Aliarmo trigerio nustatymai", + "all": "Visi", + "api-feature-hint": "Jei laukas tuščias, trigeris bus taikomas visoms API funkcijoms", + "api-usage-trigger-settings": "API naudojimo trigerio nustatymai", + "new-platform-version-trigger-settings": "Naujos platformos versijos trigerio nustatymai", + "rate-limits-trigger-settings": "Viršytų užklausų ribų trigerio nustatymai", + "task-processing-failure-trigger-settings": "Užduočių apdorojimo klaidų trigerio nustatymai", + "resources-shortage-trigger-settings": "Išteklių trūkumo trigerio nustatymai", + "at-least-one-should-be-selected": "Reikia pasirinkti bent vieną", + "basic-settings": "Pagrindiniai nustatymai", + "button-text": "Mygtuko tekstas", + "button-text-required": "Mygtuko tekstas yra privalomas", + "button-text-max-length": "Mygtuko tekstas turi būti trumpesnis nei {{ length }} simbolių", + "compose": "Sukurti", + "conversation": "Pokalbis", + "conversation-required": "Pokalbis yra privalomas", + "copy-notification-template": "Kopijuoti pranešimo šabloną", + "copy-rule": "Kopijuoti taisyklę", + "copy-template": "Kopijuoti šabloną", + "create-new": "Sukurti naują", + "created": "Sukurta", + "customize-messages": "Tinkinti pranešimus", + "cpu-threshold": "CPU riba", + "delete-notification-text": "Būkite atsargūs – po patvirtinimo pranešimas taps neatstatomas.", + "delete-notification-title": "Ar tikrai norite ištrinti pranešimą?", + "delete-notifications-text": "Būkite atsargūs – po patvirtinimo pranešimai taps neatstatomi.", + "delete-notifications-title": "Ar tikrai norite ištrinti { count, plural, =1 {1 pranešimą} other {# pranešimus} }?", + "delete-recipient-text": "Būkite atsargūs – po patvirtinimo gavėjas taps neatstatomas.", + "delete-recipient-title": "Ar tikrai norite ištrinti gavėją '{{recipientName}}'?", + "delete-recipients-text": "Būkite atsargūs – po patvirtinimo gavėjai taps neatstatomi.", + "delete-recipients-title": "Ar tikrai norite ištrinti { count, plural, =1 {1 gavėją} other {# gavėjus} }?", + "delete-request-text": "Būkite atsargūs – po patvirtinimo užklausa taps neatstatoma.", + "delete-request-title": "Ar tikrai norite ištrinti užklausą?", + "delete-requests-text": "Būkite atsargūs – po patvirtinimo užklausos taps neatstatomos.", + "delete-requests-title": "Ar tikrai norite ištrinti { count, plural, =1 {1 užklausą} other {# užklausas} }?", + "delete-rule-text": "Būkite atsargūs – po patvirtinimo taisyklė taps neatstatoma.", + "delete-rule-title": "Ar tikrai norite ištrinti taisyklę '{{ruleName}}'?", + "delete-rules-text": "Būkite atsargūs – po patvirtinimo taisyklės taps neatstatomos.", + "delete-rules-title": "Ar tikrai norite ištrinti { count, plural, =1 {1 taisyklę} other {# taisykles} }?", + "delete-template-text": "Būkite atsargūs – po patvirtinimo šablonas taps neatstatomas.", + "delete-template-title": "Ar tikrai norite ištrinti šabloną '{{templateName}}'?", + "delete-templates-text": "Būkite atsargūs – po patvirtinimo šablonai taps neatstatomi.", + "delete-templates-title": "Ar tikrai norite ištrinti { count, plural, =1 {1 šabloną} other {# šablonus} }?", + "deleted": "Ištrinta", + "delivery-method": { + "delivery-method": "Pristatymo būdas", + "email": "El. paštas", + "email-preview": "El. pašto pranešimo peržiūra", + "slack": "Slack", + "slack-preview": "Slack pranešimo peržiūra", + "microsoft-teams": "Microsoft Teams", + "microsoft-teams-preview": "Microsoft Teams pranešimo peržiūra", + "sms": "SMS", + "sms-preview": "SMS pranešimo peržiūra", + "web": "Žiniatinklis", + "web-preview": "Žiniatinklio pranešimo peržiūra", + "mobile-app": "Mobilioji programėlė", + "mobile-app-preview": "Mobiliosios programėlės pranešimo peržiūra" + }, + "delivery-method-not-configure-click": "Pristatymo būdas nesukonfigūruotas. Spustelėkite, kad nustatytumėte.", + "delivery-method-not-configure-contact": "Pristatymo būdas nesukonfigūruotas. Susisiekite su sistemos administratoriumi.", + "delivery-methods": "Pristatymo būdai", + "description": "Aprašymas", + "device-activity-trigger-settings": "Įrenginio aktyvumo trigerio nustatymai", + "device-list-rule-hint": "Jei laukas tuščias, trigeris bus taikomas visiems įrenginiams", + "device-profiles-list-rule-hint": "Jei laukas tuščias, trigeris bus taikomas visiems įrenginių profiliams", + "disabled": "Išjungta", + "edge-trigger-settings": "Edge trigerio nustatymai", + "edge-list-rule-hint": "Jei laukas tuščias, trigeris bus taikomas visoms Edge instancijoms", + "edit-notification-recipients-group": "Redaguoti pranešimų gavėjų grupę", + "edit-notification-template": "Redaguoti pranešimo šabloną", + "edit-rule": "Redaguoti taisyklę", + "edit-template": "Redaguoti šabloną", + "enabled": "Įjungta", + "entities-limit-trigger-settings": "Objektų limito trigerio nustatymai", + "entity-action-trigger-settings": "Objekto veiksmo trigerio nustatymai", + "entity-type": "Objekto tipas", + "escalation-chain": "Eskalacijos grandinė", + "failed-send": "Siuntimo klaidos", + "fails": "{ count, plural, =1 {1 klaida} other {# klaidos} }", + "filter": "Filtras", + "first-recipient": "Pirmasis gavėjas", + "inactive": "Neaktyvus", + "inbox": "Gautieji", + "notification-inbox": "Pranešimai / Gautieji", + "input-field-support-templatization": "Įvesties laukas palaiko šablonizaciją.", + "input-fields-support-templatization": "Įvesties laukai palaiko šablonizaciją.", + "link": "Nuoroda", + "link-required": "Nuoroda yra privaloma", + "link-max-length": "Nuoroda turi būti ne ilgesnė kaip {{ length }} simbolių", + "link-type": { + "dashboard": "Atidaryti valdymo skydą", + "link": "Atidaryti URL nuorodą" + }, + "loading-notifications": "Įkeliami pranešimai...", + "management": "Pranešimų valdymas", + "mark-all-as-read": "Pažymėti visus kaip skaitytus", + "mark-as-read": "Pažymėti kaip skaitytą", + "message": "Žinutė", + "message-required": "Žinutė yra privaloma", + "message-max-length": "Žinutė turi būti ne ilgesnė kaip {{ length }} simbolių", + "name": "Pavadinimas", + "name-required": "Pavadinimas yra privalomas", + "new-notification": "Naujas pranešimas", + "no-inbox-notification": "Pranešimų nerasta", + "no-notification-request": "Pranešimo užklausų nėra", + "no-notification-templates": "Pranešimų šablonų nerasta", + "no-notifications-yet": "Kol kas pranešimų nėra", + "no-recipients-notification": "Nėra pranešimo gavėjų", + "no-recipients-matching": "Gavėjų atitinkančių '{{entity}}' nerasta.", + "no-recipients-text": "Gavėjų nerasta", + "no-rule": "Nėra sukonfigūruotos taisyklės", + "no-rules-notification": "Nėra pranešimų taisyklių", + "no-severity-found": "Prioritetų nerasta", + "no-severity-matching": "Prioritetas '{{severity}}' nerastas.", + "no-template-matching": "Resurso, atitinkančio '{{template}}', nerasta.", + "create-new-template": "Sukurti naują!", + "not-found-slack-recipient": "Slack gavėjas nerastas", + "notification": "Pranešimas", + "notification-center": "Pranešimų centras", + "notification-tap-action": "Veiksmas paspaudus pranešimą", + "notification-tap-action-hint": "Jei neįjungta, bus naudojamas numatytasis aliarmo prietaisų skydas", + "notify": "Pranešti", + "notify-again": "Pranešti dar kartą", + "notify-alarm-action": { + "acknowledged": "Aliarmas patvirtintas", + "assigned": "Aliarmas priskirtas", + "cleared": "Aliarmas išvalytas", + "created": "Aliarmas sukurtas", + "severity-changed": "Aliarmo prioritetas pakeistas", + "unassigned": "Aliarmas nepriskirtas" + }, + "notify-on": "Pranešti apie", + "notify-on-comment-update": "Pranešti apie komentaro atnaujinimą", + "notify-on-required": "„Pranešti apie“ yra privalomas", + "notify-on-unassign": "Pranešti apie nepriskyrimą", + "notify-only-user-comments": "Pranešti tik apie vartotojų komentarus", + "only-rule-chain-lifecycle-failures": "Tik taisyklių grandinės ciklo klaidos", + "only-rule-node-lifecycle-failures": "Tik taisyklių mazgų ciklo klaidos", + "platform-users": "Platformos vartotojai", + "ram-threshold": "RAM riba", + "rate-limits": "Naudojimo apribojimai", + "rate-limits-hint": "Jei laukas tuščias, trigeris bus taikomas visiems naudojimo apribojimams", + "recipient": "Gavėjas", + "recipient-group": "Gavėjų grupė", + "recipient-type": { + "affected-tenant-administrators": "Paveikti nuomininko administratoriai", + "affected-user": "Paveiktas vartotojas", + "all-users": "Visi vartotojai", + "customer-users": "Klientų vartotojai", + "system-administrators": "Sistemos administratoriai", + "tenant-administrators": "Nuomininko administratoriai", + "user-filters": "Vartotojų filtras", + "user-list": "Vartotojų sąrašas", + "users-entity-owner": "Subjekto savininko vartotojai" + }, + "recipients": "Gavėjai", + "notification-recipient": "Pranešimo gavėjas", + "notification-recipient-required": "Pranešimo gavėjas yra privalomas.", + "notification-recipients": "Pranešimai / Gavėjai", + "recipients-count": "{ count, plural, =1 {1 gavėjas} other {# gavėjai} }", + "recipients-required": "Gavėjai yra privalomi", + "refresh-allow-delivery-method": "Atnaujinti leidžiamą pristatymo metodą", + "request-search": "Užklausos paieška", + "request-status": { + "processing": "Vykdoma", + "scheduled": "Suplanuota", + "sent": "Išsiųsta" + }, + "review": "Peržiūra", + "rule": "Taisyklė", + "rule-chain-list-rule-hint": "Jei laukas tuščias, trigeris bus taikomas visoms taisyklių grandinėms", + "rule-engine-events-trigger-settings": "Taisyklių variklio įvykių trigerio nustatymai", + "rule-engine-filter": "Taisyklių variklio filtras", + "rule-name": "Taisyklės pavadinimas", + "rule-name-required": "Pavadinimas yra privalomas", + "rule-disable": "Išjungti pranešimų taisyklę", + "rule-enable": "Įjungti pranešimų taisyklę", + "rule-node-filter": "Taisyklių mazgo filtras", + "rules": "Taisyklės", + "notification-rules": "Pranešimai / Taisyklės", + "scheduler-later": "Suplanuoti vėlesniam laikui", + "search-notification": "Ieškoti pranešimų", + "search-recipients": "Ieškoti gavėjų", + "search-rules": "Ieškoti taisyklių", + "search-templates": "Ieškoti šablonų", + "see-documentation": "Peržiūrėti dokumentaciją", + "selected-notifications": "{ count, plural, =1 {1 pranešimas} other {# pranešimai} } pasirinkta", + "selected-recipients": "{ count, plural, =1 {1 gavėjas} other {# gavėjai} } pasirinkta", + "selected-requests": "{ count, plural, =1 {1 užklausa} other {# užklausos} } pasirinkta", + "selected-rules": "{ count, plural, =1 {1 taisyklė} other {# taisyklės} } pasirinkta", + "selected-template": "{ count, plural, =1 {1 šablonas} other {# šablonai} } pasirinkta", + "send-notification": "Siųsti pranešimą", + "sent": "Išsiųsta", + "setup": "Nustatymai", + "notification-sent": "Pranešimai / Išsiųsti", + "set-entity-from-notification": "Nustatyti objektą iš pranešimo į valdymo suvestinę", + "slack-chanel-type": "Slack kanalo tipas", + "slack-chanel-types": { + "direct": "Tiesioginė žinutė", + "private-channel": "Privatus kanalas", + "public-channel": "Viešas kanalas" + }, + "start-from-scratch": "Pradėti nuo pradžių", + "status": "Būsena", + "stop-escalation-alarm-status-become": "Sustabdyti eskalaciją, kai aliarmo būsena tampa:", + "storage-threshold": "Saugyklos riba", + "subject": "Tema", + "subject-required": "Tema yra privaloma", + "subject-max-length": "Temos ilgis turi būti ne didesnis nei {{ length }} simbolių", + "template": "Šablonas", + "template-name": "Šablono pavadinimas", + "template-required": "Šablonas yra privalomas", + "template-type": { + "alarm": "Aliarmas", + "alarm-assignment": "Aliarmo priskyrimas", + "alarm-comment": "Aliarmo komentaras", + "api-usage-limit": "API naudojimo limitas", + "device-activity": "Įrenginio aktyvumas", + "entities-limit": "Objektų limitas", + "entity-action": "Objekto veiksmas", + "general": "Bendras", + "rule-engine-lifecycle-event": "Taisyklių variklio gyvavimo ciklo įvykis", + "rule-node": "Taisyklių mazgas", + "new-platform-version": "Nauja platformos versija", + "rate-limits": "Viršytos normos ribos", + "edge-communication-failure": "Edge ryšio klaida", + "edge-connection": "Edge prisijungimas", + "task-processing-failure": "Užduoties apdorojimo klaida", + "resources-shortage": "Išteklių trūkumas" + }, + "templates": "Šablonai", + "notification-templates": "Pranešimai / Šablonai", + "tenant-profiles-list-rule-hint": "Jei laukas tuščias, trigeris bus taikomas visiems nuomininkų profiliams", + "tenants-list-rule-hint": "Jei laukas tuščias, trigeris bus taikomas visiems nuomininkams", + "threshold": "Riba", + "theme-color": "Temos spalva", + "time": "Laikas", + "track-rule-node-events": "Stebėti taisyklių mazgų įvykius", + "trigger": { + "alarm": "Aliarmas", + "alarm-assignment": "Aliarmo priskyrimas", + "alarm-comment": "Aliarmo komentaras", + "api-usage-limit": "API naudojimo limitas", + "device-activity": "Įrenginio aktyvumas", + "entities-limit": "Objektų limitas", + "entity-action": "Objekto veiksmas", + "rule-engine-lifecycle-event": "Taisyklių variklio gyvavimo ciklo įvykis", + "new-platform-version": "Nauja platformos versija", + "rate-limits": "Viršytos normos ribos", + "edge-connection": "Edge prisijungimas", + "edge-communication-failure": "Edge ryšio klaida", + "task-processing-failure": "Užduoties apdorojimo klaida", + "resources-shortage": "Išteklių trūkumas", + "trigger": "Trigeris", + "trigger-required": "Trigeris yra privalomas" + }, + "type": "Tipas", + "unread": "Neskaitytas", + "updated": "Atnaujintas", + "use-deprecated-webhook-connectors": "Naudoti pasenusius Webhook jungiklius", + "use-old-api": "Naudoti seną API", + "use-template": "Naudoti šabloną", + "view-all": "Peržiūrėti visus", + "warning": "Įspėjimas", + "webhook-url": "Webhook URL", + "webhook-url-required": "Webhook URL yra privalomas", + "workflow-url": "Darbo eigos URL", + "workflow-url-required": "Darbo eigos URL yra privalomas", + "channel-name": "Kanalas", + "channel-name-required": "Kanalas yra privalomas", + "settings": { + "notification-settings": "Pranešimų nustatymai", + "reset-all": "Atstatyti visus nustatymus", + "reset-all-title": "Ar tikrai norite atstatyti formą?", + "reset-all-text": "Po patvirtinimo nustatymų forma bus atstatyta į numatytąsias reikšmes ir išsaugota.", + "type": "Tipas", + "enable-all": "Įjungti visus", + "disable-all": "Išjungti visus", + "delivery-not-configured": "Pristatymo metodas nesukonfigūruotas" + } + }, + "ota-update": { + "add": "Pridėti paketą", + "assign-firmware": "Priskirta programinė aparatinė įranga", + "assign-firmware-required": "Priskirta programinė aparatinė įranga yra privaloma", + "assign-software": "Priskirta programinė įranga", + "assign-software-required": "Priskirta programinė įranga yra privaloma", + "auto-generate-checksum": "Automatiškai generuoti kontrolinę sumą", + "checksum": "Kontrolinė suma", + "checksum-hint": "Jei kontrolinė suma tuščia, ji bus sugeneruota automatiškai", + "checksum-algorithm": "Kontrolinės sumos algoritmas", + "checksum-copied-message": "Paketo kontrolinė suma nukopijuota į iškarpinę", + "change-firmware": "Programinės aparatinės įrangos pakeitimas gali sukelti { count, plural, =1 {1 įrenginio} other {# įrenginių} } atnaujinimą.", + "change-software": "Programinės įrangos pakeitimas gali sukelti { count, plural, =1 {1 įrenginio} other {# įrenginių} } atnaujinimą.", + "change-ota-setting-title": "Ar tikrai norite pakeisti OTA nustatymus?", + "chose-compatible-device-profile": "Įkeltas paketas bus pasiekiamas tik įrenginiams su pasirinktu profiliu.", + "chose-firmware-distributed-device": "Pasirinkite programinę aparatinę įrangą, kuri bus paskirstyta įrenginiams", + "chose-software-distributed-device": "Pasirinkite programinę įrangą, kuri bus paskirstyta įrenginiams", + "content-type": "Turinio tipas", + "copy-checksum": "Kopijuoti kontrolinę sumą", + "copy-direct-url": "Kopijuoti tiesioginį URL", + "copyId": "Kopijuoti paketo ID", + "copied": "Nukopijuota!", + "delete": "Ištrinti paketą", + "delete-ota-update-text": "Būkite atsargūs – po patvirtinimo OTA atnaujinimas bus negrįžtamai pašalintas.", + "delete-ota-update-title": "Ar tikrai norite ištrinti OTA atnaujinimą '{{title}}'?", + "delete-ota-updates-text": "Būkite atsargūs – po patvirtinimo visi pasirinkti OTA atnaujinimai bus pašalinti.", + "delete-ota-updates-title": "Ar tikrai norite ištrinti { count, plural, =1 {1 OTA atnaujinimą} other {# OTA atnaujinimus} }?", + "description": "Aprašymas", + "direct-url": "Tiesioginis URL", + "direct-url-copied-message": "Paketo tiesioginis URL nukopijuotas į iškarpinę", + "direct-url-required": "Tiesioginis URL yra privalomas", + "download": "Atsisiųsti paketą", + "drop-file": "Įmeskite paketo failą arba spustelėkite norėdami įkelti.", + "drop-package-file-or": "Nutempkite paketo failą arba", + "file-name": "Failo pavadinimas", + "file-size": "Failo dydis", + "file-size-bytes": "Failo dydis baitais", + "idCopiedMessage": "Paketo ID nukopijuotas į iškarpinę", + "no-firmware-matching": "Nerasta jokių suderinamų programinės aparatinės įrangos OTA atnaujinimo paketų, atitinkančių '{{entity}}'.", + "no-firmware-text": "Nėra suderinamų programinės aparatinės įrangos OTA atnaujinimo paketų.", + "no-packages-text": "Paketų nerasta", + "no-software-matching": "Nerasta jokių suderinamų programinės įrangos OTA atnaujinimo paketų, atitinkančių '{{entity}}'.", + "no-software-text": "Nėra suderinamų programinės įrangos OTA atnaujinimo paketų.", + "ota-update": "OTA atnaujinimas", + "ota-update-details": "OTA atnaujinimo informacija", + "ota-updates": "OTA atnaujinimai", + "package-file": "Paketo failas", + "package-type": "Paketo tipas", + "packages-repository": "Paketų saugykla", + "search": "Ieškoti paketų", + "selected-package": "{ count, plural, =1 {1 paketas} other {# paketai} } pasirinkta", + "title": "Pavadinimas", + "title-required": "Pavadinimas yra privalomas.", + "title-max-length": "Pavadinimas turi būti trumpesnis nei 256 simboliai", + "types": { + "firmware": "Programinė aparatinė įranga", + "software": "Programinė įranga" + }, + "upload-binary-file": "Įkelti dvejetainį failą", + "use-external-url": "Naudoti išorinį URL", + "version": "Versija", + "version-required": "Versija yra privaloma.", + "version-tag": "Versijos žyma", + "version-tag-hint": "Pasirinktinė žyma turi atitikti paketo versiją, kurią praneša jūsų įrenginys.", + "version-max-length": "Versija turi būti trumpesnė nei 256 simboliai", + "warning-after-save-no-edit": "Įkėlus paketą, pavadinimo, versijos, įrenginio profilio ir paketo tipo keisti nebegalėsite." + }, + "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": "Kopijuoti JWT raktą", + "jwt-token": "JWT raktas", + "token-valid-till": "Raktas galioja iki", + "tokenCopiedSuccessMessage": "JWT raktas nukopijuotas į iškarpinę", + "tokenCopiedWarnMessage": "JWT raktas nebegalioja! Atnaujinkite puslapį." + }, + "profiles": { + "profiles": "Profiliai" + }, + "security": { + "security": "Saugumas", + "general-settings": "Bendrieji saugumo nustatymai", + "access-token": "Prieigos raktas", + "access-token-required": "Prieigos raktas yra privalomas", + "clientId": "Kliento ID", + "clientId-required": "Kliento ID yra privalomas", + "username": "Vartotojo vardas", + "username-required": "Vartotojo vardas yra privalomas", + "ca-cert": "CA sertifikatas", + "2fa": { + "2fa": "Dviejų veiksnių autentifikavimas", + "2fa-description": "Dviejų veiksnių autentifikavimas apsaugo jūsų paskyrą nuo neteisėtos prieigos. Prisijungdami turėsite įvesti saugos kodą.", + "authenticate-with": "Galite autentifikuotis naudodami:", + "disable-2fa-provider-text": "Išjungus {{name}}, jūsų paskyra taps mažiau saugi.", + "disable-2fa-provider-title": "Ar tikrai norite išjungti {{name}}?", + "get-new-code": "Gauti naują kodą", + "main-2fa-method": "Naudoti kaip pagrindinį dviejų veiksnių autentifikavimo būdą", + "dialog": { + "activation-step-description-email": "Kitą kartą prisijungiant bus prašoma įvesti saugos kodą, kuris bus išsiųstas jūsų el. pašto adresu.", + "activation-step-description-sms": "Kitą kartą prisijungiant bus prašoma įvesti saugos kodą, kuris bus išsiųstas jūsų telefono numeriu.", + "activation-step-description-totp": "Kitą kartą prisijungiant turėsite pateikti dviejų veiksnių autentifikavimo kodą.", + "activation-step-label": "Aktyvacija", + "backup-code-description": "Atsispausdinkite atsarginius kodus, kad turėtumėte juos po ranka, kai reikės prisijungti prie paskyros. Kiekvienas atsarginis kodas gali būti naudojamas tik vieną kartą.", + "backup-code-warn": "Palikus šį puslapį, šie kodai nebebus rodomi. Išsaugokite juos saugiai.", + "download-txt": "Atsisiųsti (txt)", + "email-step-description": "Įveskite el. pašto adresą, kuris bus naudojamas kaip autentifikatorius.", + "email-step-label": "El. paštas", + "enable-email-title": "Įjungti el. pašto autentifikatorių", + "enable-sms-title": "Įjungti SMS autentifikatorių", + "enable-totp-title": "Įjungti autentifikavimo programėlę", + "enter-verification-code": "Įveskite 6 skaitmenų kodą", + "get-backup-code-title": "Gauti atsarginį kodą", + "next": "Toliau", + "scan-qr-code": "Nuskaitykite šį QR kodą naudodami autentifikavimo programėlę", + "send-code": "Siųsti kodą", + "sms-step-description": "Įveskite telefono numerį, kuris bus naudojamas kaip autentifikatorius.", + "sms-step-label": "Telefono numeris", + "success": "Sėkmingai!", + "totp-step-description-install": "Galite įdiegti tokias programėles kaip Google Authenticator, Authy arba Duo.", + "totp-step-description-open": "Atidarykite autentifikavimo programėlę savo telefone.", + "totp-step-label": "Gauti programėlę", + "verification-code": "6 skaitmenų kodas", + "verification-code-invalid": "Neteisingas kodo formatas", + "verification-code-incorrect": "Neteisingas patvirtinimo kodas", + "verification-code-many-request": "Per daug bandymų patikrinti kodą", + "verification-step-description": "Įveskite 6 skaitmenų kodą, kurį ką tik išsiuntėme adresu {{address}}", + "verification-step-label": "Patvirtinimas" + }, + "provider": { "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)" + "email-description": "Naudokite saugos kodą, išsiųstą į jūsų el. pašto adresą, kad autentifikuotumėtės.", + "email-hint": "Autentifikavimo kodai siunčiami el. paštu adresu {{ info }}", + "sms": "SMS", + "sms-description": "Naudokite savo telefoną autentifikacijai. Prisijungiant išsiųsime jums saugos kodą SMS žinute.", + "sms-hint": "Autentifikavimo kodai siunčiami SMS žinute numeriu {{ info }}", + "totp": "Autentifikavimo programėlė", + "totp-description": "Naudokite programėles, tokias kaip Google Authenticator, Authy ar Duo, savo telefone autentifikacijai. Jos generuos prisijungimo kodą.", + "totp-hint": "Autentifikavimo programėlė sukonfigūruota jūsų paskyrai", + "backup_code": "Atsarginis kodas", + "backup-code-description": "Šie vienkartiniai spausdinami kodai leidžia prisijungti, kai neturite prieigos prie telefono, pavyzdžiui, keliaujant.", + "backup-code-hint": "Šiuo metu aktyvūs {{ info }} vienkartinio naudojimo kodai" + } }, - "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", - "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į" + "password-requirement": { + "at-least": "Bent:", + "character": "{ count, plural, =1 {1 simbolis} other {# simboliai(-ų)} }", + "digit": "{ count, plural, =1 {1 skaitmuo} other {# skaitmenys(-ų)} }", + "incorrect-password-try-again": "Neteisingas slaptažodis. Bandykite dar kartą", + "lowercase-letter": "{ count, plural, =1 {1 mažoji raidė} other {# mažosios raidės(-ių)} }", + "new-passwords-not-match": "Nauji slaptažodžiai nesutampa", + "password-should-not-contain-spaces": "Slaptažodyje negali būti tarpų", + "password-not-meet-requirements": "Slaptažodis neatitinka reikalavimų", + "password-requirements": "Slaptažodžio reikalavimai", + "password-should-difference": "Naujas slaptažodis turi skirtis nuo dabartinio", + "special-character": "{ count, plural, =1 {1 specialus simbolis} other {# specialūs simboliai(-ių)} }", + "uppercase-letter": "{ count, plural, =1 {1 didžioji raidė} other {# didžiosios raidės(-ių)} }", + "at-most": "Daugiausia:" + } + }, + "relation": { + "relations": "Ryšiai", + "direction": "Kryptis", + "clear-relation-type": "Aiškus ryšio tipas", + "search-direction": { + "FROM": "Iš", + "TO": "Į" }, - "details": { - "details": "Informacija", - "edit-mode": "Redagavimo režimas", - "edit-json": "Redaguoti JSON", - "toggle-edit-mode": "Redagavimo režimas" + "direction-type": { + "FROM": "iš", + "TO": "į" }, - "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", + "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šį", + "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", + "relation-filter": "Relation filter", + "additional-info": "Papildoma informacija (JSON)", + "invalid-additional-info": "Papildomos JSON informacijos išanalizuoti nepavyko.", + "no-relations-text": "Ryšių nėra", + "not": "Ne" + }, + "resource": { + "add": "Pridėti išteklių", + "all-types": "Visi", + "copyId": "Kopijuoti ištekliaus ID", + "delete": "Ištrinti išteklių", + "delete-resource-text": "Būkite atsargūs, po patvirtinimo šis išteklius bus negrįžtamai pašalintas.", + "delete-resource-title": "Ar tikrai norite ištrinti išteklių '{{resourceTitle}}'?", + "delete-resources-action-title": "Ištrinti { count, plural, =1 {1 išteklių} other {# išteklius(-ių)} }", + "delete-resources-text": "Atkreipkite dėmesį, kad pasirinkti ištekliai bus ištrinti, net jei jie naudojami įrenginių profiliuose.", + "delete-resources-title": "Ar tikrai norite ištrinti { count, plural, =1 {1 išteklių} other {# išteklius(-ių)} }?", + "download": "Atsisiųsti išteklių", + "drop-file": "Įkelkite išteklių failą arba spustelėkite norėdami pasirinkti failą.", + "drop-resource-file-or": "Vilkdami įkelkite išteklių failą arba", + "empty": "Išteklius tuščias", + "file-name": "Failo pavadinimas", + "idCopiedMessage": "Išteklius ID nukopijuotas į iškarpinę", + "no-resource-matching": "Nerasta ištekliaus, atitinkančio '{{widgetsBundle}}'.", + "no-resource-text": "Išteklių nerasta", + "open-widgets-bundle": "Atidaryti valdiklių paketą", + "resource": "Išteklius", + "resource-file": "Išteklių failas", + "resource-files": "Išteklių failai", + "resource-library-details": "Išteklių informacija", + "resource-type": "Išteklių tipas", + "resources-library": "Išteklių biblioteka", + "search": "Ieškoti išteklių", + "selected-resources": "{ count, plural, =1 {1 išteklius} other {# ištekliai(-ių)} } pasirinkta", + "system": "Sistema", + "title": "Pavadinimas", + "title-required": "Pavadinimas yra privalomas.", + "title-max-length": "Pavadinimas turi būti trumpesnis nei 256 simboliai", + "type": { + "jks": "JKS", + "js-module": "JS modulis", + "lwm2m-model": "LWM2M modelis", + "pkcs-12": "PKCS #12" + }, + "resource-sub-type": "Potipio", + "sub-type": { + "image": "Paveikslėlis", + "scada-symbol": "SCADA simbolis", + "extension": "Plėtinys", + "module": "Modulis" + } + }, + "javascript": { + "add": "Pridėti JavaScript išteklių", + "delete": "Ištrinti JavaScript išteklių", + "delete-javascript-resource-text": "Būkite atsargūs – po patvirtinimo šis JavaScript išteklius bus nebeatkuriamas.", + "delete-javascript-resource-title": "Ar tikrai norite ištrinti JavaScript išteklių '{{resourceTitle}}'?", + "delete-javascript-resources-action-title": "Ištrinti JavaScript { count, plural, =1 {1 išteklių} other {# išteklius} }", + "delete-javascript-resources-text": "Atkreipkite dėmesį, kad pasirinkti JavaScript ištekliai, net jei jie naudojami JavaScript funkcijose, bus ištrinti.", + "delete-javascript-resources-title": "Ar tikrai norite ištrinti JavaScript { count, plural, =1 {1 išteklių} other {# išteklius} }?", + "delete-javascript-resource-in-use-text": "Jei vis tiek norite ištrinti šį JavaScript išteklių, spustelėkite mygtuką Ištrinti vis tiek.", + "download": "Atsisiųsti JavaScript išteklių", + "upload-from-file": "Įkelti JavaScript iš failo", + "resource-file": "JavaScript ištekliaus failas", + "drop-file": "Įmeskite JavaScript failą arba spustelėkite, kad pasirinktumėte failą įkėlimui.", + "drop-resource-file-or": "Vilkite ir numeskite JavaScript failą arba", + "javascript-library": "JavaScript biblioteka", + "javascript-type": "JavaScript tipas", + "javascript-resource-details": "JavaScript ištekliaus detalės", + "javascript-resource-is-in-use": "JavaScript išteklius naudojamas kituose objektuose", + "javascript-resources-are-in-use": "JavaScript ištekliai naudojami kituose objektuose", + "javascript-resource-is-in-use-text": "JavaScript išteklius '{{title}}' nebuvo ištrintas, nes jį naudoja šie objektai:", + "javascript-resources-are-in-use-text": "Ne visi JavaScript ištekliai buvo ištrinti, nes jie naudojami kituose objektuose.
    Galite peržiūrėti susijusius objektus spustelėdami mygtuką Nuorodos atitinkamoje eilutėje.
    Jei vis tiek norite ištrinti šiuos JavaScript išteklius, pasirinkite juos lentelėje ir spustelėkite mygtuką Ištrinti pasirinktus.", + "search": "Ieškoti JavaScript išteklių", + "selected-javascript-resources": "{ count, plural, =1 {1 JavaScript išteklius} other {# JavaScript ištekliai} } pasirinkta", + "no-javascript-resource-text": "JavaScript išteklių nerasta", + "all-types": "Visi", + "module-script": "Modulio scenarijus" + }, + "rpc": { + "error": { + "target-device-is-not-set": "Tikslinis įrenginys nenustatytas!", + "invalid-target-entity": "RPC komandos nepalaikomos {{entityType}} objektui.", + "failed-to-resolve-target-device": "Nepavyko nustatyti tikslo įrenginio!", + "request-timeout": "Užklausos laikas baigėsi", + "rpc-http-error": "Klaida: {{status}} - {{statusText}}" + } + }, + "rulechain": { + "rulechain": "Taisyklių grandinė", + "rulechain-events": "Taisyklių grandinės įvykiai", + "rulechains": "Taisyklių grandinės", + "root": "Pagrindinė", + "delete": "Ištrinti taisyklių grandinę", + "name": "Pavadinimas", + "name-required": "Pavadinimas yra privalomas.", + "name-max-length": "Pavadinimas turi būti trumpesnis nei 256 simboliai", + "description": "Aprašymas", + "add": "Pridėti taisyklių grandinę", + "set-root": "Padaryti pagrindine", + "set-root-rulechain-title": "Ar tikrai norite taisyklių grandinę '{{ruleChainName}}' padaryti pagrindine?", + "set-root-rulechain-text": "Patvirtinus ši taisyklių grandinė taps pagrindine ir tvarkys visus įeinančius transporto pranešimus.", + "delete-rulechain-title": "Ar tikrai norite ištrinti taisyklių grandinę '{{ruleChainName}}'?", + "delete-rulechain-text": "Atsargiai, po patvirtinimo ši taisyklių grandinė ir visi susiję duomenys bus negrįžtamai pašalinti.", + "delete-rulechains-title": "Ar tikrai norite ištrinti { count, plural, =1 {1 taisyklių grandinę} other {# taisyklių grandines} }?", + "delete-rulechains-action-title": "Ištrinti { count, plural, =1 {1 taisyklių grandinę} other {# taisyklių grandines} }", + "delete-rulechains-text": "Atsargiai, po patvirtinimo visos pažymėtos taisyklių grandinės bus pašalintos ir visi susiję duomenys bus prarasti.", + "add-rulechain-text": "Pridėti naują taisyklių grandinę", + "no-rulechains-text": "Taisyklių grandinių nerasta", + "rulechain-details": "Taisyklių grandinės detalės", + "details": "Detalės", + "events": "Įvykiai", + "system": "Sistema", + "import": "Importuoti taisyklių grandinę", + "export": "Eksportuoti taisyklių grandinę", + "export-failed-error": "Nepavyko eksportuoti taisyklių grandinės: {{error}}", + "create-new-rulechain": "Sukurti naują taisyklių grandinę", + "rulechain-file": "Taisyklių grandinės failas", + "invalid-rulechain-file-error": "Nepavyko importuoti taisyklių grandinės: netinkama duomenų struktūra.", + "copyId": "Kopijuoti taisyklių grandinės ID", + "idCopiedMessage": "Taisyklių grandinės ID nukopijuotas į iškarpinę", + "select-rulechain": "Pasirinkti taisyklių grandinę", + "no-rulechains-matching": "Taisyklių grandinių, atitinkančių '{{entity}}', nerasta.", + "rulechain-required": "Taisyklių grandinė yra privaloma", + "management": "Taisyklių valdymas", + "debug-mode": "Derinimo režimas", + "search": "Ieškoti taisyklių grandinių", + "selected-rulechains": "{ count, plural, =1 {1 taisyklių grandinė} other {# taisyklių grandinės} } pasirinkta", + "open-rulechain": "Atidaryti taisyklių grandinę", + "edge-template-root": "Edge šablono pagrindas", + "assign-to-edge": "Priskirti Edge", + "edge-rulechain": "Edge taisyklių grandinė", + "unassign-rulechain-from-edge-text": "Patvirtinus, taisyklių grandinė bus atšaukta ir nebebus pasiekiama Edge įrenginiui.", + "unassign-rulechains-from-edge-title": "Ar tikrai norite atšaukti { count, plural, =1 {1 taisyklių grandinę} other {# taisyklių grandines} }?", + "unassign-rulechains-from-edge-text": "Patvirtinus visos pažymėtos taisyklių grandinės bus atšauktos ir nebebus prieinamos Edge įrenginiui.", + "assign-rulechain-to-edge-title": "Priskirti taisyklių grandinę (-es) Edge įrenginiui", + "assign-rulechain-to-edge-text": "Pasirinkite taisyklių grandines, kurias norite priskirti Edge įrenginiui", + "set-edge-template-root-rulechain": "Padaryti taisyklių grandinę Edge šablono pagrindine", + "set-edge-template-root-rulechain-title": "Ar tikrai norite taisyklių grandinę '{{ruleChainName}}' padaryti Edge šablono pagrindine?", + "set-edge-template-root-rulechain-text": "Patvirtinus ši taisyklių grandinė taps pagrindine naujai kuriamiems Edge įrenginiams.", + "invalid-rulechain-type-error": "Nepavyko importuoti taisyklių grandinės: netinkamas tipas. Tikimasi tipo {{expectedRuleChainType}}.", + "set-auto-assign-to-edge": "Automatiškai priskirti taisyklių grandinę Edge įrenginiams kūrimo metu", + "set-auto-assign-to-edge-title": "Ar tikrai norite automatiškai priskirti Edge taisyklių grandinę '{{ruleChainName}}' naujai kuriamiems Edge įrenginiams?", + "set-auto-assign-to-edge-text": "Patvirtinus ši taisyklių grandinė bus automatiškai priskiriama Edge įrenginiams jų kūrimo metu.", + "unset-auto-assign-to-edge": "Nepriskirti taisyklių grandinės Edge įrenginiams kūrimo metu", + "unset-auto-assign-to-edge-title": "Ar tikrai nenorite automatiškai priskirti Edge taisyklių grandinės '{{ruleChainName}}' naujai kuriamiems Edge įrenginiams?", + "unset-auto-assign-to-edge-text": "Patvirtinus ši taisyklių grandinė nebus automatiškai priskiriama Edge įrenginiams.", + "unassign-rulechain-title": "Ar tikrai norite atšaukti taisyklių grandinę '{{ruleChainName}}'?", + "unassign-rulechains": "Atšaukti taisyklių grandines" + }, + "rulenode": { + "rule-node-events": "Taisyklių mazgų įvykiai", + "details": "Detalės", + "events": "Įvykiai", + "search": "Ieškoti mazgų", + "open-node-library": "Atidaryti mazgų biblioteką", + "close-node-library": "Uždaryti mazgų biblioteką", + "add": "Pridėti taisyklės mazgą", + "name": "Pavadinimas", + "name-required": "Pavadinimas yra privalomas.", + "name-max-length": "Pavadinimas turi būti trumpesnis nei 256 simboliai", + "type": "Tipas", + "rule-node-description": "Taisyklių mazgo aprašymas", + "delete": "Ištrinti taisyklės mazgą", + "select-all-objects": "Pažymėti visus mazgus ir jungtis", + "deselect-all-objects": "Atžymėti visus mazgus ir jungtis", + "delete-selected-objects": "Ištrinti pažymėtus mazgus ir jungtis", + "delete-selected": "Ištrinti pažymėtus", + "create-nested-rulechain": "Sukurti įdėtą taisyklių grandinę", + "select-all": "Pažymėti visus", + "copy-selected": "Kopijuoti pažymėtus", + "deselect-all": "Atžymėti visus", + "rulenode-details": "Taisyklių mazgo detalės", + "debug-mode": "Derinimo režimas", + "singleton": "Vienetinis (Singleton)", + "configuration": "Konfigūracija", + "link": "Jungtis", + "link-details": "Taisyklių mazgo jungties detalės", + "add-link": "Pridėti jungtį", + "link-label": "Jungties pavadinimas", + "link-label-required": "Jungties pavadinimas yra privalomas.", + "custom-link-label": "Tinkintas jungties pavadinimas", + "custom-link-label-required": "Tinkintas jungties pavadinimas yra privalomas.", + "link-labels": "Jungčių pavadinimai", + "link-labels-required": "Jungčių pavadinimai yra privalomi.", + "no-link-labels-found": "Jungčių pavadinimų nerasta", + "no-link-label-matching": "Jungtis '{{label}}' nerasta.", + "create-new-link-label": "Sukurti naują!", + "type-filter": "Filtras", + "type-filter-details": "Filtruoti įeinančias žinutes pagal konfigūruotas sąlygas", + "type-enrichment": "Praturtinimas", + "type-enrichment-details": "Papildyti žinutės metaduomenis papildoma informacija", + "type-transformation": "Transformacija", + "type-transformation-details": "Keisti žinutės turinį arba metaduomenis", + "type-action": "Veiksmas", + "type-action-details": "Atlikti specialų veiksmą", + "type-external": "Išorinis", + "type-external-details": "Sąveikauja su išorine sistema", + "type-rule-chain": "Taisyklių grandinė", + "type-rule-chain-details": "Perduoda įeinančias žinutes nurodytai taisyklių grandinei", + "type-flow": "Srautas", + "type-flow-details": "Organizuoja žinučių srautą", + "type-input": "Įvestis", + "type-input-details": "Taisyklių grandinės loginė įvestis, perduodanti žinutes tolesniam susijusiam mazgui", + "type-unknown": "Nežinomas", + "type-unknown-details": "Nesprendžiamas taisyklių mazgas", + "directive-is-not-loaded": "Nurodyta konfigūracijos direktyva '{{directiveName}}' nėra prieinama.", + "ui-resources-load-error": "Nepavyko įkelti konfigūracijos UI išteklių.", + "invalid-target-rulechain": "Nepavyko nustatyti paskirties taisyklių grandinės!", + "test-script-function": "Testuoti skripto funkciją", + "script-lang-java-script": "JavaScript", + "script-lang-tbel": "TBEL", + "message": "Žinutė", + "message-type": "Žinutės tipas", + "select-message-type": "Pasirinkti žinutės tipą", + "message-type-required": "Žinutės tipas yra privalomas", + "metadata": "Metaduomenys", + "metadata-required": "Metaduomenų įrašai negali būti tušti.", + "output": "Išvestis", + "test": "Testuoti", + "help": "Pagalba", + "reset-debug-settings": "Atstatyti derinimo nustatymus visuose mazguose", + "test-with-this-message": "{{test}} su šia žinute", + "queue-hint": "Pasirinkite eilę, į kurią bus persiunčiamos žinutės. Numatytoji eilė – 'Main'.", + "queue-singleton-hint": "Pasirinkite eilę žinučių perdavimui kelių egzempliorių aplinkoje. Numatytoji eilė – 'Pagrindinis'." + }, + "rule-node-config": { + "id": "Id", + "additional-info": "Papildoma informacija", + "advanced-settings": "Išplėstiniai nustatymai", + "create-entity-if-not-exists": "Sukurti naują objektą, jei jis neegzistuoja", + "create-entity-if-not-exists-hint": "Jei įjungta, naujas objektas su nurodytais parametrais bus sukurtas, nebent toks jau egzistuoja. Esami objektai bus naudojami tokie, kokie yra, ryšio užmezgimui.", + "select-device-connectivity-event": "Pasirinkite įrenginio ryšio įvykį", + "entity-name-pattern": "Pavadinimo šablonas", + "device-name-pattern": "Įrenginio pavadinimas", + "asset-name-pattern": "Turto pavadinimas", + "entity-view-name-pattern": "Objekto peržiūros pavadinimas", + "customer-title-pattern": "Kliento pavadinimas", + "dashboard-name-pattern": "Valdymo skydelio pavadinimas", + "user-name-pattern": "Naudotojo el. paštas", + "edge-name-pattern": "Edge pavadinimas", + "entity-name-pattern-required": "Pavadinimo šablonas yra privalomas", + "entity-name-pattern-hint": "Pavadinimo šablono laukas palaiko šablonizaciją. Naudokite $[messageKey], kad gautumėte reikšmę iš pranešimo, ir ${metadataKey}, kad gautumėte reikšmę iš metaduomenų.", + "copy-message-type": "Kopijuoti pranešimo tipą", + "entity-type-pattern": "Tipo šablonas", + "entity-type-pattern-required": "Tipo šablonas yra privalomas", + "message-type-value": "Pranešimo tipo reikšmė", + "message-type-value-required": "Pranešimo tipo reikšmė yra privaloma", + "message-type-value-max-length": "Pranešimo tipo reikšmė turi būti trumpesnė nei 256 simboliai", + "output-message-type": "Išvesties pranešimo tipas", + "entity-cache-expiration": "Objektų talpyklos galiojimo laikas (sek.)", + "entity-cache-expiration-hint": "Nurodo maksimalų laiką, kiek galima saugoti rastų objektų įrašus. Reikšmė 0 reiškia, kad įrašai niekada nesibaigs.", + "entity-cache-expiration-required": "Objektų talpyklos galiojimo laikas yra privalomas.", + "entity-cache-expiration-range": "Objektų talpyklos galiojimo laikas turi būti didesnis arba lygus 0.", + "customer-name-pattern": "Kliento pavadinimas", + "customer-name-pattern-required": "Kliento pavadinimas yra privalomas", + "customer-name-pattern-hint": "Naudokite $[messageKey], kad gautumėte reikšmę iš pranešimo, ir ${metadataKey}, kad gautumėte reikšmę iš metaduomenų.", + "create-customer-if-not-exists": "Sukurti naują klientą, jei jis neegzistuoja", + "unassign-from-customer": "Atšaukti priskyrimą klientui, jei siuntėjas yra valdymo skydelis", + "unassign-from-customer-tooltip": "Tik valdymo skydeliai gali būti priskirti keliems klientams vienu metu.\nJei pranešimo siuntėjas yra valdymo skydelis, turite aiškiai nurodyti kliento pavadinimą, kad jį atšauktumėte.", + "customer-cache-expiration": "Klientų talpyklos galiojimo laikas (sek.)", + "customer-cache-expiration-hint": "Nurodo maksimalų laiką, kiek galima saugoti rastų klientų įrašus. Reikšmė 0 reiškia, kad įrašai niekada nesibaigs.", + "customer-cache-expiration-required": "Klientų talpyklos galiojimo laikas yra privalomas.", + "customer-cache-expiration-range": "Klientų talpyklos galiojimo laikas turi būti didesnis arba lygus 0.", + "interval-start": "Intervalo pradžia", + "interval-end": "Intervalo pabaiga", + "time-unit": "Laiko vienetas", + "fetch-mode": "Gavimo režimas", + "order-by-timestamp": "Rikiuoti pagal laiko žymą", + "limit": "Riba", + "limit-hint": "Mažiausia riba – 2, didžiausia – 1000. Jei norite gauti vieną įrašą, pasirinkite gavimo režimą 'Pirmas' arba 'Paskutinis'.", + "limit-required": "Riba yra privaloma.", + "limit-range": "Riba turi būti nuo 2 iki 1000.", + "time-unit-milliseconds": "Milisekundės", + "time-unit-seconds": "Sekundės", + "time-unit-minutes": "Minutės", + "time-unit-hours": "Valandos", + "time-unit-days": "Dienos", + "time-value-range": "Leidžiamas diapazonas nuo 1 iki 2147483647.", + "start-interval-value-required": "Intervalo pradžia yra privaloma.", + "end-interval-value-required": "Intervalo pabaiga yra privaloma.", + "filter": "Filtras", + "switch": "Perjungiklis", + "math-templatization-tooltip": "Šis laukas palaiko šablonizaciją. Naudokite $[messageKey], kad gautumėte reikšmę iš pranešimo, ir ${metadataKey}, kad gautumėte reikšmę iš metaduomenų.", + "add-message-type": "Pridėti pranešimo tipą", + "select-message-types-required": "Turi būti pasirinktas bent vienas pranešimo tipas.", + "select-message-types": "Pasirinkti pranešimo tipus", + "no-message-types-found": "Pranešimo tipų nerasta", + "no-message-type-matching": "'{{messageType}}' nerasta.", + "create-new-message-type": "Sukurti naują.", + "message-types-required": "Pranešimo tipai yra privalomi.", + "client-attributes": "Kliento atributai", + "shared-attributes": "Bendri atributai", + "server-attributes": "Serverio atributai", + "attributes-keys": "Atributų raktai", + "attributes-keys-required": "Atributų raktai yra privalomi", + "attributes-scope": "Atributų sritis", + "attributes-scope-value": "Atributų srities reikšmė", + "attributes-scope-value-copy": "Kopijuoti atributų srities reikšmę", + "attributes-scope-hint": "Naudokite metaduomenų raktą 'scope', kad dinamiškai nustatytumėte atributų sritį kiekvienam pranešimui. Jei pateikta, tai perrašo konfigūracijoje nustatytą sritį.", + "notify-device": "Priverstinai pranešti įrenginiui", + "send-attributes-updated-notification": "Siųsti atnaujintų atributų pranešimą", + "send-attributes-updated-notification-hint": "Siųsti pranešimą apie atnaujintus atributus kaip atskirą žinutę į taisyklių variklio eilę.", + "send-attributes-deleted-notification": "Siųsti ištrintų atributų pranešimą", + "send-attributes-deleted-notification-hint": "Siųsti pranešimą apie ištrintus atributus kaip atskirą žinutę į taisyklių variklio eilę.", + "update-attributes-only-on-value-change": "Išsaugoti atributus tik jei pasikeitė reikšmė", + "update-attributes-only-on-value-change-hint": "Atnaujina atributus su kiekvienu gaunamu pranešimu, neatsižvelgiant į tai, ar reikšmė pasikeitė. Tai padidina API apkrovą ir sumažina našumą.", + "update-attributes-only-on-value-change-hint-enabled": "Atnaujina atributus tik jei jų reikšmė pasikeitė. Jei reikšmė nepasikeitė, atributų laiko žyma ir pranešimas apie pakeitimą nebus siunčiami.", + "fetch-credentials-to-metadata": "Įtraukti prisijungimo duomenis į metaduomenis", + "notify-device-on-update-hint": "Jei įjungta, priverstinai praneša įrenginiui apie bendrų atributų atnaujinimą. Jei išjungta, pranešimo elgesį valdo 'notifyDevice' parametras iš gaunamo pranešimo metaduomenų. Norėdami išjungti pranešimą, pranešimo metaduomenyse turi būti parametras 'notifyDevice' nustatytas į 'false'. Kitais atvejais pranešimas bus siunčiamas įrenginiui.", + "notify-device-on-delete-hint": "Jei įjungta, priverstinai praneša įrenginiui apie bendrų atributų pašalinimą. Jei išjungta, pranešimo elgesį valdo 'notifyDevice' parametras iš gaunamo pranešimo metaduomenų. Norėdami įjungti pranešimą, pranešimo metaduomenyse turi būti parametras 'notifyDevice' nustatytas į 'true'. Kitais atvejais pranešimas įrenginiui nebus siunčiamas.", + "latest-timeseries": "Naujausių laiko eilučių duomenų raktai", + "timeseries-keys": "Laiko eilučių raktai", + "timeseries-keys-required": "Turi būti pasirinktas bent vienas laiko eilučių raktas.", + "add-timeseries-key": "Pridėti laiko eilučių raktą", + "add-message-field": "Pridėti pranešimo lauką", + "relation-search-parameters": "Ryšių paieškos parametrai", + "relation-parameters": "Ryšių parametrai", + "add-metadata-field": "Pridėti metaduomenų lauką", + "data-keys": "Pranešimo laukų pavadinimai", + "copy-from": "Kopijuoti iš", + "data-to-metadata": "Duomenis į metaduomenis", + "metadata-to-data": "Metaduomenis į duomenis", + "use-regular-expression-hint": "Naudokite reguliariąją išraišką raktų kopijavimui pagal šabloną.\n\nPatarimai:\nPaspauskite 'Enter', kad užbaigtumėte lauko pavadinimo įvedimą.\nPaspauskite 'Backspace', kad ištrintumėte lauko pavadinimą. Palaikomi keli pavadinimai.", + "interval": "Intervalas", + "interval-required": "Intervalas yra privalomas", + "interval-hint": "Dublikavimo intervalas sekundėmis.", + "interval-min-error": "Mažiausia leidžiama reikšmė yra 1", + "max-pending-msgs": "Maks. laukiančių pranešimų", + "max-pending-msgs-hint": "Maksimalus pranešimų skaičius, saugomas atmintyje kiekvienam unikaliam dublikavimo ID.", + "max-pending-msgs-required": "Maks. laukiančių pranešimų reikšmė yra privaloma", + "max-pending-msgs-max-error": "Didžiausia leidžiama reikšmė yra 1000", + "max-pending-msgs-min-error": "Mažiausia leidžiama reikšmė yra 1", + "max-retries": "Maksimalus bandymų skaičius", + "max-retries-required": "Maksimalus bandymų skaičius yra privalomas", + "max-retries-hint": "Didžiausias bandymų skaičius išsiųsti dublikuotus pranešimus į eilę. Tarp bandymų taikomas 10 sekundžių delsimas.", + "max-retries-max-error": "Didžiausia leidžiama reikšmė yra 100", + "max-retries-min-error": "Mažiausia leidžiama reikšmė yra 0", + "strategy": "Strategija", + "strategy-required": "Strategija yra privaloma", + "strategy-all-hint": "Grąžina visus pranešimus, gautus dublikavimo laikotarpiu, kaip vieną JSON masyvą, kur kiekvienas elementas turi objektą su 'msg' ir 'metadata' savybėmis.", + "strategy-first-hint": "Grąžina pirmą pranešimą, gautą dublikavimo laikotarpiu.", + "strategy-last-hint": "Grąžina paskutinį pranešimą, gautą dublikavimo laikotarpiu.", + "first": "Pirmas", + "last": "Paskutinis", + "all": "Visi", + "output-msg-type-hint": "Dublikavimo rezultato pranešimo tipas.", + "queue-name-hint": "Eilės pavadinimas, į kurią bus publikuojamas dublikavimo rezultatas.", + "keys": "Raktai", + "keys-required": "Raktai yra privalomi", + "rename-keys-in": "Pervadinti raktus", + "data": "Duomenys", + "message": "Pranešimas", + "metadata": "Metaduomenys", + "current-key-name": "Dabartinis rakto pavadinimas", + "key-name-required": "Rakto pavadinimas yra privalomas", + "new-key-name": "Naujas rakto pavadinimas", + "new-key-name-required": "Naujas rakto pavadinimas yra privalomas", + "metadata-keys": "Metaduomenų laukų pavadinimai", + "json-path-expression": "JSON kelio išraiška", + "json-path-expression-required": "JSON kelio išraiška yra privaloma", + "json-path-expression-hint": "JSONPath nurodo kelią į elementą arba elementų rinkinį JSON struktūroje. '$' reiškia šakninį objektą arba masyvą.", + "relations-query": "Ryšių užklausa", + "device-relations-query": "Įrenginio ryšių užklausa", + "max-relation-level": "Didžiausias ryšių lygis", + "max-relation-level-error": "Reikšmė turi būti didesnė nei 0 arba nenustatyta.", + "max-relation-level-invalid": "Reikšmė turi būti sveikasis skaičius.", + "relation-type": "Ryšio tipas", + "relation-type-pattern": "Ryšio tipo šablonas", + "relation-type-pattern-required": "Ryšio tipo šablonas yra privalomas", + "relation-types-list": "Ryšių tipai, kuriuos reikia perduoti", + "relation-types-list-hint": "Jei nepasirinkti ryšių tipai, signalai (alarmai) bus perduodami be filtravimo pagal ryšio tipą.", + "unlimited-level": "Neribotas lygis", + "latest-telemetry": "Naujausia telemetrija", + "add-telemetry-key": "Pridėti telemetrijos raktą", + "delete-from": "Ištrinti iš", + "use-regular-expression-delete-hint": "Naudokite reguliariąją išraišką raktų ištrynimui pagal šabloną.\n\nPatarimai:\nPaspauskite 'Enter', kad užbaigtumėte lauko pavadinimo įvedimą.\nPaspauskite 'Backspace', kad ištrintumėte lauko pavadinimą.\nPalaikomi keli pavadinimai.", + "fetch-into": "Užkrauti į", + "attr-mapping": "Atributų susiejimas:", + "source-attribute": "Pirminio atributo raktas", + "source-attribute-required": "Pirminio atributo raktas yra privalomas.", + "source-telemetry": "Pirminės telemetrijos raktas", + "source-telemetry-required": "Pirminės telemetrijos raktas yra privalomas.", + "target-key": "Tikslinis raktas", + "target-key-required": "Tikslinis raktas yra privalomas.", + "attr-mapping-required": "Turi būti nurodyta bent viena susiejimo eilutė.", + "fields-mapping": "Laukų susiejimas", + "fields-mapping-hint": "Jei pranešimo laukas nustatytas į $entityId, pranešimo kilmės (originatoriaus) ID bus įrašytas į nurodytą lentelės stulpelį.", + "relations-query-config-direction-suffix": "kilmės objektas", + "profile-name": "Profilio pavadinimas", + "fetch-circle-parameter-info-from-metadata-hint": "Metaduomenų laukas '{{perimeterKeyName}}' turi būti pateiktas šiuo formatu: {\"latitude\":48.196, \"longitude\":24.6532, \"radius\":100.0, \"radiusUnit\":\"METER\"}", + "fetch-poligon-parameter-info-from-metadata-hint": "Metaduomenų laukas '{{perimeterKeyName}}' turi būti pateiktas šiuo formatu: [[48.19736,24.65235],[48.19800,24.65060],...,[48.19849,24.65420]]", + "short-templatization-tooltip": "Naudokite $[messageKey], kad gautumėte reikšmę iš pranešimo, ir ${metadataKey}, kad gautumėte reikšmę iš metaduomenų.", + "fields-mapping-required": "Turi būti nurodytas bent vienas laukų susiejimas.", + "at-least-one-field-required": "Bent vienas įvesties laukas turi turėti reikšmę (-es).", + "originator-fields-sv-map-hint": "Tikslinių raktų laukai palaiko šablonizaciją. Naudokite $[messageKey], kad gautumėte reikšmę iš pranešimo, ir ${metadataKey}, kad gautumėte reikšmę iš metaduomenų.", + "sv-map-hint": "Tik tikslinių raktų laukai palaiko šablonizaciją. Naudokite $[messageKey], kad gautumėte reikšmę iš pranešimo, ir ${metadataKey}, kad gautumėte reikšmę iš metaduomenų.", + "source-field": "Pirminis laukas", + "source-field-required": "Pirminis laukas yra privalomas.", + "originator-source": "Kilmės šaltinis", + "new-originator": "Naujas kilmės objektas", + "originator-customer": "Klientas", + "originator-tenant": "Nuomininkas", + "originator-related": "Susijęs objektas", + "originator-alarm-originator": "Signalo kilmės objektas", + "originator-entity": "Objektas pagal pavadinimo šabloną", + "clone-message": "Klonuoti pranešimą", + "transform": "Transformuoti", + "default-ttl": "Numatytasis TTL", + "default-ttl-required": "Numatytasis TTL yra privalomas.", + "default-ttl-hint": "Taisyklės mazgas (Rule Node) paims Time-to-Live (TTL) reikšmę iš pranešimo metaduomenų. Jei reikšmės nėra, bus naudojamas konfigūracijoje nurodytas TTL. Jei reikšmė lygi 0, bus taikomas TTL iš nuomininko (Tenant) profilio konfigūracijos.", + "default-ttl-zero-hint": "TTL nebus taikomas, jei jo reikšmė nustatyta į 0.", + "min-default-ttl-message": "Leidžiama tik minimali 0 TTL reikšmė.", + "generation-parameters": "Generavimo parametrai", + "message-count": "Generuojamų pranešimų limitas (0 - neribota)", + "message-count-required": "Generuojamų pranešimų limitas yra privalomas.", + "min-message-count-message": "Leidžiama tik minimali 0 pranešimų reikšmė.", + "period-seconds": "Periodas sekundėmis", + "period-seconds-required": "Periodas yra privalomas.", + "generation-frequency-seconds": "Generavimo dažnis sekundėmis", + "generation-frequency-required": "Generavimo dažnis yra privalomas.", + "min-generation-frequency-message": "Leidžiamas mažiausias 60 sekundžių intervalas.", + "script-lang-tbel": "TBEL", + "script-lang-js": "JS", + "use-metadata-period-in-seconds-patterns": "Naudoti periodo sekundėmis šabloną", + "use-metadata-period-in-seconds-patterns-hint": "Jei pasirinkta, taisyklės mazgas naudos periodo sekundėmis šabloną iš pranešimo metaduomenų arba duomenų, laikydamas, kad intervalai pateikti sekundėmis.", + "period-in-seconds-pattern": "Periodo sekundėmis šablonas", + "period-in-seconds-pattern-required": "Periodo sekundėmis šablonas yra privalomas", + "min-period-seconds-message": "Leidžiamas mažiausias 60 sekundžių periodas.", + "originator": "Kilmės objektas", + "message-body": "Pranešimo turinys", + "message-metadata": "Pranešimo metaduomenys", + "generate": "Generuoti", + "current-rule-node": "Dabartinis taisyklės mazgas", + "current-tenant": "Dabartinis nuomininkas", + "generator-function": "Generatoriaus funkcija", + "test-generator-function": "Testuoti generatoriaus funkciją", + "generator": "Generatorius", + "test-filter-function": "Testuoti filtravimo funkciją", + "test-switch-function": "Testuoti perjungimo funkciją", + "test-transformer-function": "Testuoti transformavimo funkciją", + "transformer": "Transformatorius", + "alarm-create-condition": "Signalo (aliarmo) sukūrimo sąlyga", + "test-condition-function": "Testuoti sąlygos funkciją", + "alarm-clear-condition": "Signalo (aliarmo) išvalymo sąlyga", + "alarm-details-builder": "Signalo (aliarmo) informacijos kūrėjas", + "test-details-function": "Testuoti informacijos kūrimo funkciją", + "alarm-type": "Signalo tipas", + "select-entity-types": "Pasirinkti objektų tipus", + "alarm-type-required": "Signalo tipas yra privalomas.", + "alarm-severity": "Signalo svarba", + "alarm-severity-required": "Signalo svarba yra privaloma", + "alarm-severity-pattern": "Signalo svarbos šablonas", + "alarm-status-filter": "Signalo būsenos filtras", + "alarm-status-list-empty": "Signalo būsenų sąrašas tuščias", + "no-alarm-status-matching": "Nerasta atitinkančių signalo būsenų.", + "propagate": "Perduoti signalą susijusiems objektams", + "propagate-to-owner": "Perduoti signalą objekto savininkui (klientui arba nuomininkui)", + "propagate-to-tenant": "Perduoti signalą nuomininkui", + "condition": "Sąlyga", + "details": "Išsami informacija", + "to-string": "Į eilutę (string)", + "test-to-string-function": "Testuoti konvertavimo į eilutę funkciją", + "from-template": "Iš", + "from-template-required": "Laukas „Iš“ yra privalomas", + "message-to-metadata": "Pranešimą į metaduomenis", + "metadata-to-message": "Metaduomenis į pranešimą", + "from-message": "Iš pranešimo", + "from-metadata": "Iš metaduomenų", + "to-template": "Į", + "to-template-required": "Laukas „Į šabloną“ yra privalomas", + "mail-address-list-template-hint": "Adreso sąrašas atskiriamas kableliais. Naudokite ${metadataKey} reikšmėms iš metaduomenų ir $[messageKey] reikšmėms iš pranešimo turinio gauti.", + "cc-template": "Kopija (Cc)", + "bcc-template": "Slapta kopija (Bcc)", + "subject-template": "Tema", + "subject-template-required": "Temos šablonas yra privalomas", + "body-template": "Turinys", + "body-template-required": "Turinio šablonas yra privalomas", + "dynamic-mail-body-type": "Dinaminio el. laiško turinio tipas", + "mail-body-type": "El. laiško turinio tipas", + "body-type-template": "Turinio tipo šablonas", + "reply-routing-configuration": "Atsakymų nukreipimo konfigūracija", + "rpc-reply-routing-configuration-hint": "Šie konfigūracijos parametrai nurodo metaduomenų raktų pavadinimus, naudojamus paslaugos, sesijos ir užklausos identifikavimui siunčiant atsakymą atgal.", + "reply-routing-configuration-hint": "Šie konfigūracijos parametrai nurodo metaduomenų raktų pavadinimus, naudojamus paslaugos ir užklausos identifikavimui siunčiant atsakymą atgal.", + "request-id-metadata-attribute": "Užklausos ID", + "service-id-metadata-attribute": "Paslaugos ID", + "session-id-metadata-attribute": "Sesijos ID", + "timeout-sec": "Laiko limitas (sekundėmis)", + "timeout-required": "Laiko limitas yra privalomas", + "min-timeout-message": "Leidžiama tik minimali 0 laiko limito reikšmė.", + "endpoint-url-pattern": "Galutinio taško (endpoint) URL šablonas", + "endpoint-url-pattern-required": "Galutinio taško (endpoint) URL šablonas yra privalomas", + "request-method": "Užklausos metodas", + "use-simple-client-http-factory": "Naudoti paprastą HTTP kliento kūrimo būdą", + "ignore-request-body": "Be užklausos turinio", + "parse-to-plain-text": "Konvertuoti į gryną tekstą", + "parse-to-plain-text-hint": "Jei pasirinkta, užklausos turinys bus konvertuotas iš JSON eilutės į paprastą tekstą, pvz., msg = \"Hello,\\t\"world\"\" bus konvertuota į Hello, \"world\"", + "read-timeout": "Skaitymo laiko limitas (milisekundėmis)", + "read-timeout-hint": "Reikšmė 0 reiškia neribotą laiko limitą", + "max-parallel-requests-count": "Maksimalus lygiagrečių užklausų skaičius", + "max-parallel-requests-count-hint": "Reikšmė 0 nurodo, kad lygiagretus apdorojimas nėra ribojamas", + "max-response-size": "Maksimalus atsako dydis (KB)", + "max-response-size-hint": "Didžiausias atminties kiekis, skirtas duomenų buferiavimui dekoduojant ar koduojant HTTP pranešimus, pvz., JSON ar XML turinius", + "headers": "Antraštės (Headers)", + "headers-hint": "Naudokite ${metadataKey} reikšmėms iš metaduomenų ir $[messageKey] reikšmėms iš pranešimo turinio antraščių/vertės laukuose", + "header": "Antraštė", + "header-required": "Antraštė yra privaloma", + "value": "Reikšmė", + "value-required": "Reikšmė yra privaloma", + "topic-pattern": "Temos šablonas", + "key-pattern": "Rakto šablonas", + "key-pattern-hint": "Nebūtina. Jei nurodytas galiojantis skaidinio (partition) numeris, jis bus naudojamas siunčiant įrašą. Jei skaidinys nenurodytas, bus naudojamas raktas. Jei abu nėra nurodyti – bus priskiriama apvaliuoju būdu (round-robin).", + "topic-pattern-required": "Temos šablonas yra privalomas", + "topic": "Tema", + "topic-required": "Tema yra privaloma", + "bootstrap-servers": "Bootstrap serveriai", + "bootstrap-servers-required": "Bootstrap serverių reikšmė yra privaloma", + "other-properties": "Kitos savybės", + "key": "Raktas", + "key-required": "Raktas yra privalomas", + "retries": "Automatinio bandymo kartai, jei nepavyksta", + "min-retries-message": "Leidžiama tik minimali 0 pakartojimų reikšmė.", + "batch-size-bytes": "Siunčiamo paketo dydis baitais", + "min-batch-size-bytes-message": "Leidžiama tik minimali 0 paketo dydžio reikšmė.", + "linger-ms": "Buferiavimo laikas vietoje (ms)", + "min-linger-ms-message": "Leidžiama tik minimali 0 ms reikšmė.", + "buffer-memory-bytes": "Maksimalus kliento buferio dydis baitais", + "min-buffer-memory-message": "Leidžiama tik minimali 0 buferio dydžio reikšmė.", + "memory-buffer-size-range": "Atminties buferio dydis turi būti nuo 0 iki {{max}} KB", + "acks": "Patvirtinimų skaičius (ACKs)", + "topic-arn-pattern": "Temos ARN šablonas", + "topic-arn-pattern-required": "Temos ARN šablonas yra privalomas", + "aws-access-key-id": "AWS prieigos rakto ID (Access Key ID)", + "aws-access-key-id-required": "AWS prieigos rakto ID yra privalomas", + "aws-secret-access-key": "AWS slaptasis prieigos raktas (Secret Access Key)", + "aws-secret-access-key-required": "AWS slaptasis prieigos raktas yra privalomas", + "aws-region": "AWS regionas", + "aws-region-required": "AWS regionas yra privalomas", + "exchange-name-pattern": "Mainų (Exchange) pavadinimo šablonas", + "routing-key-pattern": "Maršruto rakto (Routing key) šablonas", + "message-properties": "Pranešimo savybės", + "host": "Serveris (Host)", + "host-required": "Serverio adresas yra privalomas", + "port": "Prievadas (Port)", + "port-required": "Prievadas yra privalomas", + "port-range": "Prievadas turi būti nuo 1 iki 65535.", + "virtual-host": "Virtualus serveris (Virtual host)", + "username": "Vartotojo vardas", + "password": "Slaptažodis", + "automatic-recovery": "Automatinis atkūrimas", + "connection-timeout-ms": "Prisijungimo laiko limitas (ms)", + "min-connection-timeout-ms-message": "Leidžiama tik minimali 0 ms reikšmė.", + "handshake-timeout-ms": "Rankos paspaudimo (Handshake) laiko limitas (ms)", + "min-handshake-timeout-ms-message": "Leidžiama tik minimali 0 ms reikšmė.", + "client-properties": "Kliento savybės", + "queue-url-pattern": "Eilės (Queue) URL šablonas", + "queue-url-pattern-required": "Eilės URL šablonas yra privalomas", + "delay-seconds": "Vėlinimas (sekundėmis)", + "min-delay-seconds-message": "Leidžiama tik minimali 0 sekundžių reikšmė.", + "max-delay-seconds-message": "Leidžiama tik maksimali 900 sekundžių reikšmė.", + "name": "Pavadinimas", + "name-required": "Pavadinimas yra privalomas", + "queue-type": "Eilės tipas", + "sqs-queue-standard": "Standartinė", + "sqs-queue-fifo": "FIFO (First-In-First-Out)", + "gcp-project-id": "GCP projekto ID", + "gcp-project-id-required": "GCP projekto ID yra privalomas", + "gcp-service-account-key": "GCP paslaugos paskyros raktas (key file)", + "gcp-service-account-key-required": "GCP paslaugos paskyros raktas yra privalomas", + "pubsub-topic-name": "Temos pavadinimas", + "pubsub-topic-name-required": "Temos pavadinimas yra privalomas", + "message-attributes": "Pranešimo atributai", + "message-attributes-hint": "Naudokite ${metadataKey} reikšmėms iš metaduomenų ir $[messageKey] reikšmėms iš pranešimo turinio nurodyti vardų/vertės laukuose", + "connect-timeout": "Prisijungimo laiko limitas (sekundėmis)", + "connect-timeout-required": "Prisijungimo laiko limitas yra privalomas.", + "connect-timeout-range": "Prisijungimo laiko limitas turi būti nuo 1 iki 200.", + "client-id": "Kliento ID", + "client-id-hint": "Nebūtina. Palikite tuščią, jei ID generuojamas automatiškai. Nurodydami konkretų Client ID būkite atsargūs — dauguma MQTT brokerių neleidžia kelių jungčių su tuo pačiu ID. Jei platforma veikia mikroservisų režimu, kiekvienas servisas paleidžia atskirą šio mazgo (rule node) egzempliorių, todėl dubliuoti ID gali sukelti klaidų. Kad to išvengtumėte, įjunkite „Pridėti paslaugos ID kaip priesagą prie kliento ID“.", + "append-client-id-suffix": "Pridėti paslaugos ID kaip priesagą prie kliento ID", + "client-id-suffix-hint": "Nebūtina. Naudojama, kai „Client ID“ nurodytas rankiniu būdu. Pridėjus paslaugos ID kaip priesagą, išvengiama klaidų mikroservisų architektūroje.", + "device-id": "Įrenginio ID", + "device-id-required": "Įrenginio ID yra privalomas.", + "clean-session": "Švari sesija (Clean session)", + "enable-ssl": "Įjungti SSL", + "credentials": "Autentifikacijos duomenys", + "credentials-type": "Autentifikacijos tipas", + "credentials-type-required": "Autentifikacijos tipas yra privalomas.", + "credentials-anonymous": "Anoniminis", + "credentials-basic": "Paprastas (Basic)", + "credentials-pem": "PEM sertifikatai", + "credentials-pem-hint": "Reikalingas bent Serverio CA sertifikatas arba Kliento sertifikato ir privačiojo rakto pora", + "credentials-sas": "Bendro prieigos parašo (Shared Access Signature)", + "sas-key": "SAS raktas", + "sas-key-required": "SAS raktas yra privalomas.", + "hostname": "Serverio pavadinimas (Hostname)", + "hostname-required": "Serverio pavadinimas yra privalomas.", + "azure-ca-cert": "CA sertifikato failas", + "username-required": "Vartotojo vardas yra privalomas.", + "password-required": "Slaptažodis yra privalomas.", + "ca-cert": "Serverio CA sertifikato failas", + "private-key": "Kliento privataus rakto failas", + "cert": "Kliento sertifikato failas", + "no-file": "Failas nepasirinktas.", + "drop-file": "Nutempkite failą arba spustelėkite, kad pasirinktumėte failą įkėlimui.", + "private-key-password": "Privataus rakto slaptažodis", + "use-system-smtp-settings": "Naudoti sistemos SMTP nustatymus", + "use-metadata-dynamic-interval": "Naudoti dinaminį intervalą", + "metadata-dynamic-interval-hint": "Intervalo pradžios ir pabaigos laukai palaiko šablonizaciją (templatization). Pakeistos reikšmės turi būti milisekundėmis. Naudokite $[messageKey] reikšmei iš pranešimo ir ${metadataKey} reikšmei iš metaduomenų.", + "use-metadata-interval-patterns-hint": "Jei pasirinkta, mazgas (rule node) naudos pradžios ir pabaigos intervalo šablonus iš pranešimo duomenų arba metaduomenų, manydamas, kad intervalai yra milisekundėmis.", + "use-message-alarm-data": "Naudoti pranešimo pavojaus (alarm) duomenis", + "overwrite-alarm-details": "Perrašyti pavojaus detales", + "use-alarm-severity-pattern": "Naudoti pavojaus svarbos (severity) šabloną", + "check-all-keys": "Patikrinti, ar visi nurodyti laukai egzistuoja", + "check-all-keys-hint": "Jei pasirinkta, tikrina, ar visi nurodyti raktai yra pranešimo duomenyse ir metaduomenyse.", + "check-relation-to-specific-entity": "Tikrinti ryšį su konkrečiu objektu", + "check-relation-to-specific-entity-tooltip": "Jei įjungta, tikrinamas ryšio buvimas su konkrečiu objektu, kitu atveju – su bet kuriuo objektu. Abiem atvejais ryšys nustatomas pagal kryptį ir tipą.", + "check-relation-hint": "Tikrina ryšio buvimą su konkrečiu ar bet kuriuo objektu, atsižvelgiant į kryptį ir ryšio tipą.", + "delete-relation-with-specific-entity": "Pašalinti ryšį su konkrečiu objektu", + "delete-relation-with-specific-entity-hint": "Jei įjungta, bus pašalintas tik ryšys su vienu konkrečiu objektu. Kitu atveju, ryšys bus pašalintas su visais atitinkančiais objektais.", + "delete-relation-hint": "Pašalina ryšį tarp pranešimo kilmės (originator) ir nurodyto objekto (-ų), remiantis kryptimi ir tipu.", + "remove-current-relations": "Pašalinti esamus ryšius", + "remove-current-relations-hint": "Pašalina esamus ryšius iš pranešimo kilmės (originator) pagal kryptį ir tipą.", + "change-originator-to-related-entity": "Pakeisti pranešimo kilmę į susijusį objektą", + "change-originator-to-related-entity-hint": "Naudojama, kad pranešimas būtų apdorojamas kaip gautas iš kito objekto.", + "start-interval": "Intervalo pradžia", + "end-interval": "Intervalo pabaiga", + "start-interval-required": "Intervalo pradžia yra privaloma.", + "end-interval-required": "Intervalo pabaiga yra privaloma.", + "smtp-protocol": "Protokolas", + "smtp-host": "SMTP serveris", + "smtp-host-required": "SMTP serveris yra privalomas.", + "smtp-port": "SMTP prievadas", + "smtp-port-required": "Reikia nurodyti SMTP prievadą.", + "smtp-port-range": "SMTP prievadas turi būti nuo 1 iki 65535.", + "timeout-msec": "Laiko limitas (ms)", + "min-timeout-msec-message": "Leidžiama tik minimali 0 ms reikšmė.", + "enter-username": "Įveskite vartotojo vardą", + "enter-password": "Įveskite slaptažodį", + "enable-tls": "Įjungti TLS", + "tls-version": "TLS versija", + "enable-proxy": "Įjungti tarpiklį (Proxy)", + "use-system-proxy-properties": "Naudoti sistemos tarpiklio nustatymus", + "proxy-host": "Tarpiklio serveris (Proxy host)", + "proxy-host-required": "Tarpiklio serveris yra privalomas.", + "proxy-port": "Tarpiklio prievadas (Proxy port)", + "proxy-port-required": "Tarpiklio prievadas yra privalomas.", + "proxy-port-range": "Tarpiklio prievadas turi būti nuo 1 iki 65535.", + "proxy-user": "Tarpiklio vartotojas", + "proxy-password": "Tarpiklio slaptažodis", + "proxy-scheme": "Tarpiklio schema (Proxy scheme)", + "numbers-to-template": "Telefono numerių šablonas", + "numbers-to-template-required": "Telefono numerių šablonas yra privalomas", + "numbers-to-template-hint": "Telefono numerius atskirkite kableliais. Naudokite ${metadataKey} reikšmėms iš metaduomenų ir $[messageKey] reikšmėms iš pranešimo turinio.", + "sms-message-template": "SMS žinutės šablonas", + "sms-message-template-required": "SMS žinutės šablonas yra privalomas", + "use-system-sms-settings": "Naudoti sistemos SMS paslaugų teikėjo nustatymus", + "min-period-0-seconds-message": "Leidžiamas tik minimalus 0 sekundžių periodas.", + "max-pending-messages": "Maksimalus laukiančių pranešimų skaičius", + "max-pending-messages-required": "Maksimalus laukiančių pranešimų skaičius yra privalomas.", + "max-pending-messages-range": "Maksimalus laukiančių pranešimų skaičius turi būti nuo 1 iki 100000.", + "originator-types-filter": "Siuntėjų (originatorių) tipų filtras", + "interval-seconds": "Intervalas sekundėmis", + "interval-seconds-required": "Intervalas yra privalomas.", + "int-range": "Reikšmė neturi viršyti maksimalios sveiko skaičiaus ribos (2147483648)", + "min-interval-seconds-message": "Leidžiamas tik minimalus 1 sekundės intervalas.", + "output-timeseries-key-prefix": "Išvesties laiko eilučių rakto priešdėlis", + "output-timeseries-key-prefix-required": "Išvesties laiko eilučių rakto priešdėlis yra privalomas.", + "separator-hint": "Paspauskite „Enter“, kad užbaigtumėte lauko įvedimą.", + "select-details": "Pasirinkti detales", + "entity-details-id": "Id", + "entity-details-title": "Pavadinimas", + "entity-details-country": "Šalis", + "entity-details-state": "Valstija / regionas", + "entity-details-city": "Miestas", + "entity-details-zip": "Pašto kodas", + "entity-details-address": "Adresas", + "entity-details-address2": "Adresas 2", + "entity-details-additional_info": "Papildoma informacija", + "entity-details-phone": "Telefonas", + "entity-details-email": "El. paštas", + "email-sender": "El. pašto siuntėjas", + "fields-to-check": "Laukai, kuriuos reikia patikrinti", + "add-detail": "Pridėti detalę", + "check-all-keys-tooltip": "Jei įjungta, tikrina, ar visi pranešime ir metaduomenyse nurodyti laukai yra pranešime.", + "fields-to-check-hint": "Paspauskite „Enter“, kad užbaigtumėte lauko įvedimą. Palaikomi keli laukų pavadinimai.", + "entity-details-list-empty": "Reikia pasirinkti bent vieną detalę.", + "alarm-status": "Pavojaus būsena", + "alarm-required": "Turi būti pasirinkta bent viena pavojaus būsena.", + "no-entity-details-matching": "Nerasta atitinkančių objektų detalių.", + "custom-table-name": "Individualios lentelės pavadinimas", + "custom-table-name-required": "Lentelės pavadinimas yra privalomas", + "custom-table-hint": "Lentelė turi būti sukurta jūsų Cassandra klasteryje, jos pavadinimas turi prasidėti prefiksu „cs_tb_“, kad duomenys nebūtų įrašyti į bendras TB lenteles. Įveskite lentelės pavadinimą be „cs_tb_“ prefikso.", + "message-field": "Pranešimo laukas", + "message-field-required": "Pranešimo laukas yra privalomas.", + "table-col": "Lentelės stulpelis", + "table-col-required": "Lentelės stulpelis yra privalomas.", + "latitude-field-name": "Platumos lauko pavadinimas", + "longitude-field-name": "Ilgumos lauko pavadinimas", + "latitude-field-name-required": "Platumos lauko pavadinimas yra privalomas.", + "longitude-field-name-required": "Ilgumos lauko pavadinimas yra privalomas.", + "fetch-perimeter-info-from-metadata": "Gauti perimetro informaciją iš metaduomenų", + "fetch-perimeter-info-from-metadata-tooltip": "Jei perimetro tipas nustatytas kaip „Poligonas“, metaduomenų lauko '{{perimeterKeyName}}' reikšmė bus naudojama kaip perimetro apibrėžimas be papildomo reikšmės apdorojimo. Priešingu atveju, jei perimetro tipas nustatytas kaip „Apskritimas“, '{{perimeterKeyName}}' metaduomenų lauko reikšmė bus išanalizuota, kad būtų išgauti 'latitude', 'longitude', 'radius', 'radiusUnit' laukai, naudojami apskritimo perimetro apibrėžimui.", + "perimeter-key-name": "Perimetro rakto pavadinimas", + "perimeter-key-name-hint": "Metaduomenų lauko pavadinimas, kuriame yra perimetro informacija.", + "perimeter-key-name-required": "Perimetro rakto pavadinimas yra privalomas.", + "perimeter-circle": "Apskritimas", + "perimeter-polygon": "Poligonas", + "perimeter-type": "Perimetro tipas", + "circle-center-latitude": "Centro platuma", + "circle-center-latitude-required": "Centro platuma yra privaloma.", + "circle-center-longitude": "Centro ilguma", + "circle-center-longitude-required": "Centro ilguma yra privaloma.", + "range-unit-meter": "Metrai", + "range-unit-kilometer": "Kilometrai", + "range-unit-foot": "Pėdos", + "range-unit-mile": "Mylios", + "range-unit-nautical-mile": "Jūrmylės", + "range-units": "Atstumo vienetai", + "range-units-required": "Atstumo vienetai yra privalomi.", + "range": "Atstumas", + "range-required": "Atstumas yra privalomas.", + "polygon-definition": "Poligono apibrėžimas", + "polygon-definition-required": "Poligono apibrėžimas yra privalomas.", + "polygon-definition-hint": "Naudokite šį formatą rankiniam poligono apibrėžimui: [[lat1,lon1],[lat2,lon2], ... ,[latN,lonN]].", + "min-inside-duration": "Minimalus buvimo viduje laikas", + "min-inside-duration-value-required": "Minimalus buvimo viduje laikas yra privalomas", + "min-inside-duration-time-unit": "Minimalus buvimo viduje laiko vienetas", + "min-outside-duration": "Minimalus buvimo išorėje laikas", + "min-outside-duration-value-required": "Minimalus buvimo išorėje laikas yra privalomas", + "min-outside-duration-time-unit": "Minimalus buvimo išorėje laiko vienetas", + "tell-failure-if-absent": "Pranešti apie klaidą", + "tell-failure-if-absent-hint": "Jei bent vienas pasirinktas raktas neegzistuoja, siunčiama žinutė praneš apie „Klaida“.", + "get-latest-value-with-ts": "Gauti naujausias telemetrijos reikšmes su laiko žyma", + "get-latest-value-with-ts-hint": "Jei pasirinkta, naujausios telemetrijos reikšmės taip pat turės laiko žymą, pvz.: \"temp\": \"{\"ts\":1574329385897, \"value\":42}\"", + "ignore-null-strings": "Ignoruoti tuščias eilutes", + "ignore-null-strings-hint": "Jei pasirinkta, taisyklės mazgas ignoruos objektų laukus su tuščia reikšme.", + "add-metadata-key-values-as-kafka-headers": "Pridėti metaduomenų raktų-reikšmių poras į Kafka antraštes", + "add-metadata-key-values-as-kafka-headers-hint": "Jei pasirinkta, raktų-reikšmių poros iš metaduomenų bus pridėtos prie siunčiamų įrašų antraščių kaip baitų masyvai su iš anksto nustatytu kodavimu.", + "charset-encoding": "Koduotė", + "charset-encoding-required": "Koduotė yra privaloma.", + "charset-us-ascii": "US-ASCII", + "charset-iso-8859-1": "ISO-8859-1", + "charset-utf-8": "UTF-8", + "charset-utf-16be": "UTF-16BE", + "charset-utf-16le": "UTF-16LE", + "charset-utf-16": "UTF-16", + "select-queue-hint": "Eilės pavadinimą galima pasirinkti iš sąrašo arba įvesti rankiniu būdu.", + "device-profile-node-hint": "Naudinga, jei turite trukmės ar pasikartojančias sąlygas, kad būtų užtikrintas aliarmo būsenos vertinimo tęstinumas.", + "persist-alarm-rules": "Išsaugoti aliarmo taisyklių būseną", + "persist-alarm-rules-hint": "Jei įjungta, taisyklių mazgas išsaugos apdorojimo būseną duomenų bazėje.", + "fetch-alarm-rules": "Atkurti aliarmo taisyklių būseną", + "fetch-alarm-rules-hint": "Jei įjungta, taisyklių mazgas atkūrimo metu atkurs apdorojimo būseną ir užtikrins, kad aliarmai būtų aktyvuojami net po serverio perkrovimo. Priešingu atveju būsena bus atkurta, kai atvyks pirmoji žinutė iš įrenginio.", + "input-value-key": "Įvesties reikšmės raktas", + "input-value-key-required": "Įvesties reikšmės raktas yra privalomas.", + "output-value-key": "Išvesties reikšmės raktas", + "output-value-key-required": "Išvesties reikšmės raktas yra privalomas.", + "number-of-digits-after-floating-point": "Skaičių kiekis po kablelio", + "number-of-digits-after-floating-point-range": "Skaičių kiekis po kablelio turi būti nuo 0 iki 15.", + "failure-if-delta-negative": "Pranešti apie klaidą, jei delta yra neigiama", + "failure-if-delta-negative-tooltip": "Taisyklių mazgas privers pranešimą žymėti kaip klaidą, jei delta reikšmė neigiama.", + "use-caching": "Naudoti kešavimą", + "use-caching-tooltip": "Taisyklių mazgas kaups \"{{inputValueKey}}\" reikšmę iš gaunamos žinutės, kad pagerintų našumą. Atkreipkite dėmesį, kad kešas nebus atnaujintas, jei reikšmė bus pakeista kitur.", + "add-time-difference-between-readings": "Pridėti laiko skirtumą tarp \"{{inputValueKey}}\" nuskaitymų", + "add-time-difference-between-readings-tooltip": "Jei įjungta, taisyklių mazgas pridės \"{{periodValueKey}}\" prie siunčiamos žinutės.", + "period-value-key": "Laikotarpio reikšmės raktas", + "period-value-key-required": "Laikotarpio reikšmės raktas yra privalomas.", + "general-pattern-hint": "Naudokite ${metadataKey} reikšmei iš metaduomenų, $[messageKey] reikšmei iš pranešimo kūno.", + "alarm-severity-pattern-hint": "Naudokite ${metadataKey} reikšmei iš metaduomenų, $[messageKey] reikšmei iš pranešimo kūno. Aliarmo rimtumas turi būti sistemos (CRITICAL, MAJOR ir t. t.)", + "output-node-name-hint": "Taisyklių mazgo pavadinimas atitinka pranešimo ryšio tipą ir naudojamas pranešimams perduoti kitiems taisyklių mazgams toje pačioje taisyklių grandinėje.", + "use-server-ts": "Naudoti serverio laiko žymą", + "use-server-ts-hint": "Naudoti serverio dabartinę laiko žymą laiko eilučių duomenims, kurie neturi aiškios laiko žymos. Tai padeda išlaikyti teisingą eiliškumą apdorojant žinutes iš kelių šaltinių arba kai jos atvyksta ne iš eilės.", + "kv-map-pattern-hint": "Visi įvesties laukai palaiko šablonizaciją. Naudokite $[messageKey], kad gautumėte reikšmę iš pranešimo, ir ${metadataKey}, kad gautumėte reikšmę iš metaduomenų.", + "kv-map-single-pattern-hint": "Įvesties laukas palaiko šablonizaciją. Naudokite $[messageKey], kad gautumėte reikšmę iš pranešimo, ir ${metadataKey}, kad gautumėte reikšmę iš metaduomenų.", + "shared-scope": "Bendras taikymo sritis", + "server-scope": "Serverio taikymo sritis", + "client-scope": "Kliento taikymo sritis", + "attribute-type": "Atributas", + "attribute-type-description": "Gauti atributo reikšmę iš duomenų bazės", + "attribute-type-result-description": "Išsaugoti rezultatą kaip subjekto atributą duomenų bazėje", + "constant-type": "Konstanta", + "constant-type-description": "Nustatyti pastovią reikšmę", + "time-series-type": "Laiko eilutė", + "time-series-type-description": "Gauti naujausią laiko eilučių reikšmę iš duomenų bazės", + "time-series-type-result-description": "Išsaugoti rezultatą kaip subjekto laiko eilutę duomenų bazėje", + "message-body-type": "Pranešimas", + "message-body-type-description": "Gauti argumento reikšmę iš gaunamo pranešimo", + "message-body-type-result-description": "Pridėti rezultatą prie siunčiamo pranešimo", + "message-metadata-type": "Metaduomenys", + "message-metadata-type-description": "Gauti argumento reikšmę iš gaunamo pranešimo metaduomenų", + "message-metadata-result-description": "Pridėti rezultatą prie siunčiamo pranešimo metaduomenų", + "argument-tile": "Argumentai", + "no-arguments-prompt": "Argumentai nesukonfigūruoti", + "result-title": "Rezultatas", + "functions-field-input": "Funkcijos", + "no-option-found": "Parinkčių nerasta", + "argument-source-field-input": "Šaltinis", + "argument-source-field-input-required": "Argumento šaltinis yra privalomas.", + "argument-key-field-input": "Raktas", + "argument-key-field-input-required": "Argumento raktas yra privalomas.", + "constant-value-field-input": "Pastovioji reikšmė", + "constant-value-field-input-required": "Pastovioji reikšmė yra privaloma.", + "attribute-scope-field-input": "Atributo taikymo sritis", + "attribute-scope-field-input-required": "Atributo taikymo sritis yra privaloma.", + "default-value-field-input": "Numatytoji reikšmė", + "type-field-input": "Tipas", + "type-field-input-required": "Tipas yra privalomas.", + "key-field-input": "Raktas", + "add-entity-type": "Pridėti subjekto tipą", + "add-device-profile": "Pridėti įrenginio profilį", + "key-field-input-required": "Raktas yra privalomas.", + "number-floating-point-field-input": "Skaičių kiekis po kablelio", + "number-floating-point-field-input-hint": "Naudokite 0, kad konvertuotumėte rezultatą į sveiką skaičių", + "add-to-message-field-input": "Pridėti prie pranešimo", + "add-to-metadata-field-input": "Pridėti prie metaduomenų", + "custom-expression-field-input": "Matematinė išraiška", + "custom-expression-field-input-required": "Matematinė išraiška yra privaloma", + "custom-expression-field-input-hint": "Nurodykite matematinę išraišką įvertinimui. Numatytoji išraiška parodo, kaip konvertuoti Fahrenheit į Celsijų", + "retained-message": "Išsaugotas", + "attributes-mapping": "Atributų susiejimas", + "latest-telemetry-mapping": "Naujausios telemetrijos susiejimas", + "add-mapped-attribute-to": "Pridėti susietus atributus į", + "add-mapped-latest-telemetry-to": "Pridėti susietą naujausią telemetriją į", + "add-mapped-fields-to": "Pridėti susietus laukus į", + "add-selected-details-to": "Pridėti pasirinktą informaciją į", + "clear-selected-types": "Išvalyti pasirinktus tipus", + "clear-selected-details": "Išvalyti pasirinktą informaciją", + "clear-selected-fields": "Išvalyti pasirinktus laukus", + "clear-selected-keys": "Išvalyti pasirinktus raktus", + "geofence-configuration": "Geozonos konfigūracija", + "coordinate-field-names": "Koordinačių laukų pavadinimai", + "coordinate-field-hint": "Taisyklių mazgas bando gauti nurodytus laukus iš pranešimo. Jei jų nėra, ieškos metaduomenyse.", + "presence-monitoring-strategy": "Buvimo stebėjimo strategija", + "presence-monitoring-strategy-on-first-message": "Pirmoje žinutėje", + "presence-monitoring-strategy-on-each-message": "Kiekvienoje žinutėje", + "presence-monitoring-strategy-on-first-message-hint": "Praneša buvimo būseną „Viduje“ arba „Išorėje“ pirmoje žinutėje, kai nuo ankstesnio būsenos „Įėjo“ arba „Išėjo“ atnaujinimo praėjo nustatytas minimalus laikas.", + "presence-monitoring-strategy-on-each-message-hint": "Praneša buvimo būseną „Viduje“ arba „Išorėje“ kiekvienoje žinutėje po būsenos „Įėjo“ arba „Išėjo“ atnaujinimo.", + "fetch-credentials-to": "Gauti kredencialus į", + "add-originator-attributes-to": "Pridėti siuntėjo atributus į", + "originator-attributes": "Siuntėjo atributai", + "fetch-latest-telemetry-with-timestamp": "Gauti naujausią telemetriją su laiko žyma", + "fetch-latest-telemetry-with-timestamp-tooltip": "Jei pasirinkta, naujausios telemetrijos reikšmės bus pridėtos prie siunčiamų metaduomenų su laiko žyma, pvz.: \"{{latestTsKeyName}}\": \"{\"ts\":1574329385897, \"value\":42}\"", + "tell-failure": "Pranešti apie klaidą, jei trūksta bent vieno atributo", + "tell-failure-tooltip": "Jei bent vienas pasirinktas raktas neegzistuoja, siunčiama žinutė praneš apie „Klaida“.", + "created-time": "Sukūrimo laikas", + "chip-help": "Paspauskite 'Enter', kad užbaigtumėte {{inputName}} įvedimą.\nPaspauskite 'Backspace', kad ištrintumėte {{inputName}}.\nPalaikomos kelios reikšmės.", + "detail": "informacija", + "field-name": "lauko pavadinimas", + "device-profile": "įrenginio profilis", + "entity-type": "subjekto tipas", + "message-type": "pranešimo tipas", + "timeseries-key": "laiko eilučių raktas", + "type": "Tipas", + "first-name": "Vardas", + "last-name": "Pavardė", + "label": "Etiketė", + "originator-fields-mapping": "Siuntėjo laukų susiejimas", + "add-mapped-originator-fields-to": "Pridėti susietus siuntėjo laukus į", + "fields": "Laukai", + "skip-empty-fields": "Praleisti tuščius laukus", + "skip-empty-fields-tooltip": "Laukai su tuščiomis reikšmėmis nebus pridėti prie išvesties pranešimo ar metaduomenų.", + "fetch-interval": "Gavimo intervalas", + "fetch-strategy": "Gavimo strategija", + "fetch-timeseries-from-to": "Gauti laiko eilučių duomenis nuo {{startInterval}} {{startIntervalTimeUnit}} iki {{endInterval}} {{endIntervalTimeUnit}} atgal.", + "fetch-timeseries-from-to-invalid": "Laiko eilučių gavimo klaida („Intervalo pradžia“ turi būti mažesnė už „Intervalo pabaigą“).", + "use-metadata-dynamic-interval-tooltip": "Jei pasirinkta, taisyklių mazgas naudos dinaminį intervalo pradžios ir pabaigos laiką pagal pranešimo bei metaduomenų šablonus.", + "all-mode-hint": "Jei pasirinktas gavimo režimas „Visi“, taisyklių mazgas gaus telemetriją iš intervalo pagal konfigūruojamus užklausos parametrus.", + "first-mode-hint": "Jei pasirinktas režimas „Pirmas“, taisyklių mazgas gaus artimiausią telemetriją intervalo pradžiai.", + "last-mode-hint": "Jei pasirinktas režimas „Paskutinis“, taisyklių mazgas gaus artimiausią telemetriją intervalo pabaigai.", + "ascending": "Didėjančia tvarka", + "descending": "Mažėjančia tvarka", + "min": "Min.", + "max": "Maks.", + "average": "Vidurkis", + "sum": "Suma", + "count": "Kiekis", + "none": "Nėra", + "last-level-relation-tooltip": "Jei pasirinkta, taisyklių mazgas ieškos susijusių subjektų tik maksimaliame nustatytame ryšio lygyje.", + "last-level-device-relation-tooltip": "Jei pasirinkta, taisyklių mazgas ieškos susijusių įrenginių tik maksimaliame nustatytame ryšio lygyje.", + "data-to-fetch": "Duomenys, kuriuos reikia gauti", + "mapping-of-customers": "Klientų susiejimas", + "map-fields-required": "Visi susiejimo laukai yra privalomi.", + "attributes": "Atributai", + "related-device-attributes": "Susijusio įrenginio atributai", + "add-selected-attributes-to": "Pridėti pasirinktus atributus į", + "device-profiles": "Įrenginių profiliai", + "mapping-of-tenant": "Nuomininko susiejimas", + "add-attribute-key": "Pridėti atributo raktą", + "message-template": "Pranešimo šablonas", + "message-template-required": "Pranešimo šablonas yra privalomas", + "use-system-slack-settings": "Naudoti sistemos Slack nustatymus", + "slack-api-token": "Slack API raktas", + "slack-api-token-required": "Slack API raktas yra privalomas", + "keys-mapping": "Raktų susiejimas", + "add-key": "Pridėti raktą", + "recipients": "Gavėjai", + "message-subject-and-content": "Pranešimo tema ir turinys", + "template-rules-hint": "Abu įvesties laukai palaiko šablonizaciją. Naudokite $[messageKey] reikšmei iš pranešimo ir ${metadataKey} reikšmei iš metaduomenų.", + "originator-customer-desc": "Naudoti gaunamo pranešimo siuntėjo klientą kaip naują siuntėją.", + "originator-tenant-desc": "Naudoti dabartinį nuomininką kaip naują siuntėją.", + "originator-related-entity-desc": "Naudoti susijusį subjektą kaip naują siuntėją. Paieška atliekama pagal konfigūruotą ryšio tipą ir kryptį.", + "originator-alarm-originator-desc": "Naudoti aliarmo siuntėją kaip naują siuntėją. Tik jei gaunamo pranešimo siuntėjas yra aliarmo subjektas.", + "originator-entity-by-name-pattern-desc": "Naudoti iš duomenų bazės gautą subjektą kaip naują siuntėją. Paieška atliekama pagal subjekto tipą ir nurodytą pavadinimo šabloną.", + "email-from-template-hint": "Naudokite $[messageKey] reikšmei iš pranešimo ir ${metadataKey} reikšmei iš metaduomenų.", + "recipients-block-main-hint": "Adresų sąrašas, atskirtas kableliais. Visi įvesties laukai palaiko šablonizaciją. Naudokite $[messageKey] reikšmei iš pranešimo ir ${metadataKey} reikšmei iš metaduomenų.", + "forward-msg-default-rule-chain": "Persiųsti pranešimą į siuntėjo numatytą taisyklių grandinę", + "forward-msg-default-rule-chain-tooltip": "Jei įjungta, pranešimas bus persiųstas į siuntėjo numatytą taisyklių grandinę arba į konfigūracijoje nurodytą grandinę, jei siuntėjas neturi numatytos grandinės subjekto profilyje.", + "exclude-zero-deltas": "Pašalinti nulinės delta reikšmes iš siunčiamo pranešimo", + "exclude-zero-deltas-hint": "Jei įjungta, \"{{outputValueKey}}\" bus pridėtas prie siunčiamo pranešimo tik jei reikšmė nėra lygi nuliui.", + "exclude-zero-deltas-time-difference-hint": "Jei įjungta, \"{{outputValueKey}}\" ir \"{{periodValueKey}}\" bus pridėti prie siunčiamo pranešimo tik jei \"{{outputValueKey}}\" reikšmė nėra nulinė.", + "search-direction-from": "Nuo siuntėjo iki tikslo", + "search-direction-to": "Nuo tikslo iki siuntėjo", + "del-relation-direction-from": "Nuo siuntėjo", + "del-relation-direction-to": "Į siuntėją", + "target-entity": "Tikslinis subjektas", + "function-configuration": "Funkcijos konfigūracija", + "function-name": "Funkcijos pavadinimas", + "function-name-required": "Funkcijos pavadinimas yra privalomas.", + "qualifier": "Kvalifikatorius", + "qualifier-hint": "Jei kvalifikatorius nenurodytas, bus naudojamas numatytasis \"$LATEST\".", + "aws-credentials": "AWS kredencialai", + "connection-timeout": "Ryšio laiko limitas", + "connection-timeout-required": "Ryšio laiko limitas yra privalomas.", + "connection-timeout-min": "Minimalus ryšio laikas yra 0.", + "connection-timeout-hint": "Laikas sekundėmis, kurį sistema laukia užmezgant ryšį prieš nutraukdama bandymą. Reikšmė 0 reiškia begalybę ir nerekomenduojama.", + "request-timeout": "Užklausos laiko limitas", + "request-timeout-required": "Užklausos laiko limitas yra privalomas.", + "request-timeout-min": "Minimalus užklausos laikas yra 0.", + "request-timeout-hint": "Laikas sekundėmis, kiek sistema laukia, kol užklausa bus įvykdyta. Reikšmė 0 reiškia begalybę ir nerekomenduojama.", + "units": "Vienetai", + "tell-failure-aws-lambda": "Pranešti apie klaidą, jei AWS Lambda funkcijos vykdymas sukelia išimtį", + "tell-failure-aws-lambda-hint": "Taisyklių mazgas privers pranešimą žymėti kaip klaidą, jei AWS Lambda funkcijos vykdymas sukelia išimtį.", + "basic-mode": "Paprastas režimas", + "advanced-mode": "Išplėstinis režimas", + "save-time-series": { + "processing-settings": "Apdorojimo nustatymai", + "processing-settings-hint": "Nustatykite, kaip apdorojamos gaunamos žinutės. Paprasti apdorojimo nustatymai leidžia pasirinkti iš anksto sukonfigūruotas strategijas, o išplėstiniai – individualiai pritaikyti strategijas kiekvienam veiksmui.", + "advanced-settings-hint": "Būkite atsargūs konfigūruodami apdorojimo strategijas. Kai kurie deriniai gali sukelti netikėtą elgesį.", + "strategy": "Strategija", + "deduplication-interval": "Dublikavimo šalinimo intervalas", + "deduplication-interval-required": "Dublikavimo šalinimo intervalas yra privalomas.", + "deduplication-interval-min-max-range": "Dublikavimo šalinimo intervalas turi būti ne trumpesnis nei 1 sekundė ir ne ilgesnis nei 1 diena.", + "strategy-type": { + "every-message": "Kiekvienai žinutei", + "skip": "Praleisti", + "deduplicate": "Pašalinti dublikatus", + "web-sockets-only": "Tik WebSockets" + }, + "time-series": "Laiko eilutės", + "latest": "Naujausios reikšmės", + "web-sockets": "WebSockets", + "calculated-fields": "Apskaičiuoti laukai" + }, + "save-attribute": { + "processing-settings": "Apdorojimo nustatymai", + "processing-settings-hint": "Nustatykite, kaip bus apdorojamos gaunamos žinutės. Paprasti apdorojimo nustatymai leidžia pasirinkti iš anksto sukonfigūruotas strategijas, o išplėstiniai – pasirinkti individualias apdorojimo strategijas kiekvienam veiksmui.", + "advanced-settings-hint": "Būkite atsargūs konfigūruodami apdorojimo strategijas. Kai kurie deriniai gali sukelti netikėtą elgesį.", + "strategy": "Strategija", + "deduplication-interval": "Dublikavimo šalinimo intervalas", + "deduplication-interval-required": "Dublikavimo šalinimo intervalas yra privalomas.", + "deduplication-interval-min-max-range": "Dublikavimo šalinimo intervalas turi būti ne trumpesnis nei 1 sekundė ir ne ilgesnis nei 1 diena.", + "scope": "Taikymo sritis", + "strategy-type": { + "every-message": "Kiekvienai žinutei", + "skip": "Praleisti", + "deduplicate": "Pašalinti dublikatus", + "web-sockets-only": "Tik WebSockets" + }, + "attributes": "Atributai" + }, + "key-val": { + "key": "Raktas", + "value": "Reikšmė", + "see-examples": "Žiūrėti pavyzdžius.", + "remove-entry": "Pašalinti įrašą", + "remove-mapping-entry": "Pašalinti susiejimo įrašą", + "add-mapping-entry": "Pridėti susiejimą", + "add-entry": "Pridėti įrašą", + "copy-key-values-from": "Kopijuoti raktų-reikšmių poras iš", + "delete-key-values": "Ištrinti raktų-reikšmių poras", + "delete-key-values-from": "Ištrinti raktų-reikšmių poras iš", + "at-least-one-key-error": "Turi būti pasirinktas bent vienas raktas.", + "unique-key-value-pair-error": "'{{keyText}}' turi skirtis nuo '{{valText}}'!" + }, + "mail-body-types": { + "plain-text": "Grynasis tekstas", + "html": "HTML", + "dynamic": "Dinaminis", + "use-body-type-template": "Naudoti turinio tipo šabloną", + "plain-text-description": "Paprastas, neformatuotas tekstas be specialaus stiliaus ar formatavimo.", + "html-text-description": "Leidžia naudoti HTML žymes formatavimui, nuorodoms ir vaizdams el. laiško turinyje.", + "dynamic-text-description": "Leidžia dinamiškai naudoti Grynąjį tekstą arba HTML turinio tipą pagal šablonizacijos funkciją.", + "after-template-evaluation-hint": "Po šablono įvertinimo reikšmė turi būti „true“ HTML turiniui ir „false“ grynajam tekstui." + }, + "ai": { + "ai-model": "DI modelis", + "model": "Modelis", + "ai-model-hint": "Pasirinkite iš anksto sukonfigūruotą DI modelį užklausų, siunčiamų per šį taisyklių mazgą, apdorojimui arba naudokite „Sukurti naują“, kad sukonfigūruotumėte naują modelį.", + "prompt-settings": "Užklausos nustatymai", + "prompt-settings-hint": "Neprivalomas sistemos užklausos tekstas nustato DI bendrą vaidmenį ir apribojimus, o vartotojo užklausa apibrėžia konkrečią vykdomą užduotį. Abu laukai palaiko šablonizaciją.", + "system-prompt": "Sistemos užklausa", + "system-prompt-max-length": "Sistemos užklausa turi būti ne ilgesnė nei 500000 simbolių.", + "system-prompt-blank": "Sistemos užklausa negali būti tuščia.", + "user-prompt": "Vartotojo užklausa", + "user-prompt-required": "Vartotojo užklausa yra privaloma.", + "user-prompt-max-length": "Vartotojo užklausa turi būti ne ilgesnė nei 500000 simbolių.", + "user-prompt-blank": "Vartotojo užklausa negali būti tuščia.", + "response-format": "Atsakymo formatas", + "response-text": "Tekstas", + "response-json": "JSON", + "response-json-schema": "JSON Schema", + "response-format-hint-TEXT": "Leidžia modeliui generuoti laisvo formato tekstą, kuris gali būti arba nebūti tinkamas JSON objektas. Jei išvestis nėra tinkamas JSON objektas, ji bus automatiškai įvyniota į JSON objektą po raktu „response“.", + "response-format-hint-JSON": "Modelis privalo sugeneruoti atsakymą, kuris yra teisingas JSON objektas. Jei išvestis nėra tinkamas JSON objektas, ji bus automatiškai įvyniota į JSON objektą po raktu „response“.", + "response-format-hint-JSON_SCHEMA": "Modelis privalo sugeneruoti JSON, atitinkantį nurodytą struktūrą ir duomenų tipus pagal pateiktą schemą. Jei išvestis nėra tinkamas JSON objektas, ji bus automatiškai įvyniota į JSON objektą po raktu „response“.", + "response-json-schema-hint": "Galima įvesti bet kokią tinkamą JSON schemą, tačiau šis taisyklių mazgas palaiko tik ribotą jos funkcionalumo rinkinį. Išsamesnę informaciją rasite mazgo dokumentacijoje.", + "response-json-schema-required": "JSON schema yra privaloma.", + "advanced-settings": "Išplėstiniai nustatymai", + "timeout": "Laiko limitas", + "timeout-hint": "Didžiausias laukimo laikas, kol bus gautas atsakymas iš DI modelio prieš nutraukiant užklausą.", + "timeout-required": "Laiko limitas yra privalomas.", + "timeout-validation": "Turi būti nuo 1 sekundės iki 10 minučių.", + "force-acknowledgement": "Priverstinis patvirtinimas", + "force-acknowledgement-hint": "Jei įjungta, gaunamas pranešimas patvirtinamas iš karto. Modelio atsakymas tada įtraukiamas į eilę kaip atskiras naujas pranešimas." + } + }, + "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": "Naršyklės laikas" + }, + "queue": { + "queue-name": "Eilė", + "no-queues-found": "Eilių nerasta.", + "no-queues-matching": "Eilių, atitinkančių '{{queue}}', nerasta.", + "select-name": "Pasirinkite eilės pavadinimą", + "name": "Pavadinimas", + "name-required": "Eilės pavadinimas yra privalomas!", + "name-unique": "Eilės pavadinimas nėra unikalus!", + "name-pattern": "Eilės pavadinimas turi simbolį, kuris nėra ASCII raidinis-skaitmeninis, '.', '_' ar '-'!", + "queue-required": "Eilė yra privaloma!", + "topic-required": "Eilės tema yra privaloma!", + "poll-interval-required": "Apklausos intervalas yra privalomas!", + "poll-interval-min-value": "Apklausos intervalo reikšmė negali būti mažesnė nei 1.", + "partitions-required": "Padaliniai yra privalomi!", + "partitions-min-value": "Padalinių reikšmė negali būti mažesnė nei 1.", + "pack-processing-timeout-required": "Apdorojimo laiko limitas yra privalomas.", + "pack-processing-timeout-min-value": "Apdorojimo laiko limito reikšmė negali būti mažesnė nei 1.", + "batch-size-required": "Paketo dydis yra privalomas!", + "batch-size-min-value": "Paketo dydžio reikšmė negali būti mažesnė nei 1.", + "retries-required": "Bandymai yra privalomi!", + "retries-min-value": "Bandymai negali būti neigiami.", + "failure-percentage-required": "Klaidos procentas yra privalomas!", + "failure-percentage-min-value": "Klaidos procentas negali būti mažesnis nei 0.", + "failure-percentage-max-value": "Klaidos procentas negali būti didesnis nei 100.", + "pause-between-retries-required": "Pauzė tarp bandymų yra privaloma!", + "pause-between-retries-min-value": "Pauzės tarp bandymų reikšmė negali būti mažesnė nei 1.", + "max-pause-between-retries-required": "Maksimali pauzė tarp bandymų yra privaloma!", + "max-pause-between-retries-min-value": "Maksimalios pauzės tarp bandymų reikšmė negali būti mažesnė nei 1.", + "submit-strategy-type-required": "Pateikimo strategijos tipas yra privalomas!", + "processing-strategy-type-required": "Apdorojimo strategijos tipas yra privalomas!", + "queues": "Eilės", + "selected-queues": "Pasirinkta { count, plural, =1 {1 eilė} other {# eilės} }", + "delete-queue-title": "Ar tikrai norite ištrinti eilę '{{queueName}}'?", + "delete-queues-title": "Ar tikrai norite ištrinti { count, plural, =1 {1 eilę} other {# eiles} }?", + "delete-queue-text": "Būkite atsargūs — po patvirtinimo eilė ir visi su ja susiję duomenys bus negrįžtamai pašalinti.", + "delete-queues-text": "Po patvirtinimo visos pasirinktos eilės bus ištrintos ir taps nepasiekiamos.", + "search": "Ieškoti eilės", + "add": "Pridėti eilę", + "details": "Eilės informacija", + "topic": "Tema", + "submit-settings": "Pateikimo nustatymai", + "submit-strategy": "Strategijos tipas *", + "grouping-parameter": "Grupavimo parametras", + "processing-settings": "Bandyminių apdorojimų nustatymai", + "processing-strategy": "Apdorojimo tipas *", + "retries-settings": "Bandyminių bandymų nustatymai", + "polling-settings": "Apklausos nustatymai", + "batch-processing": "Paketo apdorojimas", + "poll-interval": "Apklausos intervalas", + "partitions": "Padaliniai", + "immediate-processing": "Tiesioginis apdorojimas", + "consumer-per-partition": "Siųsti žinučių apklausą kiekvienam vartotojui", + "consumer-per-partition-hint": "Įjungti atskirą vartotoją (-us) kiekvienam padaliniui", + "duplicate-msg-to-all-partitions": "Dubliuoti žinutę į visus padalinius", + "processing-timeout": "Apdorojimo trukmė, ms", + "batch-size": "Paketo dydis", + "retries": "Bandymai (0 – neribota)", + "failure-percentage": "Klaidingų žinučių procentas, praleidžiant bandymus", + "pause-between-retries": "Pakartoti po, sek.", + "max-pause-between-retries": "Papildomas pakartojimas po, sek.", + "delete": "Ištrinti eilę", + "copyId": "Kopijuoti eilės ID", + "idCopiedMessage": "Eilės ID nukopijuotas į iškarpinę", + "description": "Aprašymas", + "description-hint": "Šis tekstas bus rodomas eilės aprašyme vietoj pasirinktos strategijos.", + "alt-description": "Pateikimo strategija: {{submitStrategy}}, Apdorojimo strategija: {{processingStrategy}}", + "custom-properties": "Pasirinktinės savybės", + "custom-properties-hint": "Pasirinktinės eilės (temos) kūrimo savybės, pvz.: 'retention.ms:604800000;retention.bytes:1048576000'", + "strategies": { + "sequential-by-originator-label": "Sekvencinis pagal siuntėją", + "sequential-by-originator-hint": "Nauja žinutė, pvz., iš įrenginio A, nepateikiama, kol nepatvirtinama ankstesnė žinutė iš to paties įrenginio.", + "sequential-by-tenant-label": "Sekvencinis pagal nuomininką", + "sequential-by-tenant-hint": "Nauja žinutė, pvz., iš nuomininko A, nepateikiama, kol nepatvirtinama ankstesnė žinutė iš to paties nuomininko.", + "sequential-label": "Sekvencinis", + "sequential-hint": "Nauja žinutė nepateikiama, kol nepatvirtinama ankstesnė žinutė.", + "burst-label": "Srautinis (Burst)", + "burst-hint": "Visos žinutės pateikiamos taisyklių grandinėms tokia tvarka, kokia jos gaunamos.", + "batch-label": "Paketas", + "batch-hint": "Naujas paketas nepateikiamas, kol ankstesnis nepatvirtintas.", + "skip-all-failures-label": "Praleisti visas klaidas", + "skip-all-failures-hint": "Ignoruoti visas klaidas.", + "skip-all-failures-and-timeouts-label": "Praleisti visas klaidas ir laiko limitus", + "skip-all-failures-and-timeouts-hint": "Ignoruoti visas klaidas ir laiko viršijimus.", + "retry-all-label": "Kartoti visas", + "retry-all-hint": "Kartoti visas žinutes iš apdorojimo paketo.", + "retry-failed-label": "Kartoti nesėkmingas", + "retry-failed-hint": "Kartoti visas nesėkmingai apdorotas žinutes iš paketo.", + "retry-timeout-label": "Kartoti laiko viršijusias", + "retry-timeout-hint": "Kartoti visas laiko limitą viršijusias žinutes iš apdorojimo paketo.", + "retry-failed-and-timeout-label": "Kartoti nesėkmingas ir laiko viršijusias", + "retry-failed-and-timeout-hint": "Kartoti visas nesėkmingas ir laiko limitą viršijusias žinutes iš apdorojimo paketo." + } + }, + "queue-statistics": { + "queue-statistics": "Eilių statistika", + "no-queue-statistics-matching": "Eilių statistikos, atitinkančios '{{entity}}', nerasta.", + "queue-statistics-required": "Eilių statistika yra privaloma.", + "list-of-queue-statistics": "{ count, plural, =1 {Viena eilės statistika} other {# eilių statistikos sąrašas} }", + "selected-queue-statistics": "Pasirinkta { count, plural, =1 {1 eilės statistika} other {# eilių statistikos} }", + "no-queue-statistics-text": "Eilių statistikos nerasta", + "queue-statistics-starts-with": "Eilių statistika, kurios pavadinimai prasideda '{{prefix}}'" + }, + "server-error": { + "general": "Bendra serverio klaida", + "authentication": "Autentifikavimo klaida", + "jwt-token-expired": "JWT prieigos raktas pasibaigęs", + "tenant-trial-expired": "Nuomininko bandomasis laikotarpis pasibaigė", + "credentials-expired": "Prisijungimo duomenys pasibaigę", + "permission-denied": "Prieiga uždrausta", + "invalid-arguments": "Neteisingi argumentai", + "bad-request-params": "Netinkami užklausos parametrai", + "item-not-found": "Elementas nerastas", + "too-many-requests": "Per daug užklausų", + "too-many-updates": "Per daug atnaujinimų" + }, + "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", + "search": "Valdytojų paieška", + "selected-tenants": "Pasirinkta { count, plural, =1 {1 valdytojas} other {# valdytojai} }", + "isolated-tb-rule-engine": "Naudoti atskiras ThingsBoard taisyklių variklio eiles", + "isolated-tb-rule-engine-details": "Kiekvienas nuomininkas turės atskiras taisyklių variklio eiles" + }, + "tenant-profile": { + "tenant-profile": "Nuomininko profilis", + "tenant-profiles": "Nuomininkų profiliai", + "add": "Pridėti nuomininko profilį", + "add-profile": "Pridėti profilį", + "debug": "Derinimas", + "edit": "Redaguoti nuomininko profilį", + "tenant-profile-details": "Informacija apie nuomininko profilį", + "no-tenant-profiles-text": "Nuomininkų profilių nėra", + "name-max-length": "Pavadinimas negali viršyti 256 simbolių", + "search": "Nuomininkų profilių paieška", + "selected-tenant-profiles": "Pasirinkta { count, plural, =1 {1 nuomininko profilis} other {# nuomininkų profiliai} }", + "no-tenant-profiles-matching": "Nuomininkų profilių, atitinkančių '{{entity}}', nėra.", + "tenant-profile-required": "Nuomininko profilis yra privalomas", + "idCopiedMessage": "Nuomininko profilio ID nukopijuotas į iškarpinę", + "set-default": "Nustatyti kaip pagrindinį nuomininko profilį", + "delete": "Pašalinti nuomininko profilį", + "copyId": "Kopijuoti nuomininko profilio ID", + "name": "Pavadinimas", + "name-required": "Pavadinimas yra privalomas.", + "data": "Profilio duomenys", + "profile-configuration": "Profilio konfigūracija", + "description": "Aprašymas", + "default": "Pagrindinis", + "delete-tenant-profile-title": "Ar tikrai norite pašalinti nuomininko profilį '{{tenantProfileName}}'?", + "delete-tenant-profile-text": "Būkite atsargūs — po patvirtinimo nuomininko profilis ir visa su juo susijusi informacija bus negrįžtamai pašalinta.", + "delete-tenant-profiles-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 nuomininko profilį} other {# nuomininkų profilius} }?", + "delete-tenant-profiles-text": "Būkite atsargūs — po patvirtinimo visi pasirinkti nuomininkų profiliai ir su jais susijusi informacija bus negrįžtamai pašalinti.", + "set-default-tenant-profile-title": "Ar tikrai norite nuomininko profilį '{{tenantProfileName}}' nustatyti kaip pagrindinį?", + "set-default-tenant-profile-text": "Po patvirtinimo šis nuomininko profilis bus nustatytas kaip pagrindinis ir bus automatiškai priskiriamas naujiems nuomininkams, kuriems profilis nenurodytas.", + "no-tenant-profiles-found": "Nuomininkų profilių nerasta.", + "create-new-tenant-profile": "Sukurti naują!", + "create-tenant-profile": "Sukurti naują nuomininko profilį", + "import": "Importuoti nuomininko profilį", + "export": "Eksportuoti nuomininko profilį", + "export-failed-error": "Nepavyko eksportuoti nuomininko profilio: {{error}}", + "tenant-profile-file": "Nuomininko profilio failas", + "invalid-tenant-profile-file-error": "Nepavyko importuoti nuomininko profilio: neteisinga duomenų struktūra.", + "advanced-settings": "Pažangūs nustatymai", + "entities": "Subjektai", + "rule-engine": "Taisyklių variklis", + "time-to-live": "Gyvavimo trukmė (Time-to-Live)", + "calculated-fields": "Apskaičiuojami laukai", + "alarms-and-notifications": "Pavojaus signalai ir pranešimai", + "ota-files-in-bytes": "OTA failai baitais", + "ws-title": "WS", + "unlimited": "(0 – neribota)", + "maximum-devices": "Didžiausias įrenginių skaičius", + "maximum-devices-required": "Didžiausias įrenginių skaičius yra privalomas.", + "maximum-devices-range": "Didžiausias įrenginių skaičius negali būti neigiamas", + "maximum-assets": "Didžiausias išteklių (asset) skaičius", + "maximum-assets-required": "Didžiausias išteklių (asset) skaičius yra privalomas.", + "maximum-assets-range": "Didžiausias išteklių (asset) skaičius negali būti neigiamas", + "maximum-customers": "Didžiausias klientų skaičius", + "maximum-customers-required": "Didžiausias klientų skaičius yra privalomas.", + "maximum-customers-range": "Didžiausias klientų skaičius negali būti neigiamas", + "maximum-users": "Didžiausias vartotojų skaičius", + "maximum-users-required": "Didžiausias vartotojų skaičius yra privalomas.", + "maximum-users-range": "Didžiausias vartotojų skaičius negali būti neigiamas", + "maximum-dashboards": "Didžiausias skydelių (dashboard) skaičius", + "maximum-dashboards-required": "Didžiausias skydelių (dashboard) skaičius yra privalomas.", + "maximum-dashboards-range": "Didžiausias skydelių (dashboard) skaičius negali būti neigiamas", + "maximum-edges": "Didžiausias Edge įrenginių skaičius", + "maximum-edges-required": "Didžiausias Edge įrenginių skaičius yra privalomas.", + "maximum-edges-range": "Didžiausias Edge įrenginių skaičius negali būti neigiamas", + "maximum-rule-chains": "Didžiausias taisyklių grandinių (rule chains) skaičius", + "maximum-rule-chains-required": "Didžiausias taisyklių grandinių (rule chains) skaičius yra privalomas.", + "maximum-rule-chains-range": "Didžiausias taisyklių grandinių (rule chains) skaičius negali būti neigiamas", + "maximum-resources-sum-data-size": "Išteklių failų bendras dydis", + "maximum-resources-sum-data-size-required": "Išteklių failų bendras dydis yra privalomas.", + "maximum-resources-sum-data-size-range": "Išteklių failų bendras dydis negali būti neigiamas", + "maximum-resource-size": "Didžiausias vieno ištekliaus failo dydis (baitais)", + "maximum-resource-size-required": "Didžiausias ištekliaus failo dydis yra privalomas.", + "maximum-resource-size-range": "Didžiausias ištekliaus failo dydis negali būti neigiamas", + "maximum-ota-packages-sum-data-size": "OTA paketų failų bendras dydis", + "maximum-ota-package-sum-data-size-required": "OTA paketų failų bendras dydis yra privalomas.", + "maximum-ota-package-sum-data-size-range": "OTA paketų failų bendras dydis negali būti neigiamas", + "maximum-debug-duration-min": "Didžiausia derinimo trukmė (minutėmis)", + "maximum-debug-duration-min-range": "Didžiausia derinimo trukmė negali būti neigiama", + "rest-requests-for-tenant": "REST užklausos nuomininkui", + "transport-tenant-telemetry-msg-rate-limit": "Nuomininko telemetrijos pranešimų limitas", + "transport-tenant-telemetry-data-points-rate-limit": "Nuomininko telemetrijos duomenų taškų limitas", + "transport-device-msg-rate-limit": "Įrenginių pranešimų limitas", + "transport-device-telemetry-msg-rate-limit": "Įrenginių telemetrijos pranešimų limitas", + "transport-device-telemetry-data-points-rate-limit": "Įrenginių telemetrijos duomenų taškų limitas", + "transport-gateway-msg-rate-limit": "Šliuzo pranešimų limitas", + "transport-gateway-telemetry-msg-rate-limit": "Šliuzo telemetrijos pranešimų limitas", + "transport-gateway-telemetry-data-points-rate-limit": "Šliuzo telemetrijos duomenų taškų limitas", + "transport-gateway-device-msg-rate-limit": "Šliuzo įrenginių pranešimų limitas", + "transport-gateway-device-telemetry-msg-rate-limit": "Šliuzo įrenginių telemetrijos pranešimų limitas", + "transport-gateway-device-telemetry-data-points-rate-limit": "Šliuzo įrenginių telemetrijos duomenų taškų limitas", + "tenant-entity-export-rate-limit": "Objektų versijų kūrimo limitas", + "tenant-entity-import-rate-limit": "Objektų versijų įkėlimo limitas", + "tenant-notification-request-rate-limit": "Pranešimų užklausų limitas", + "tenant-notification-requests-per-rule-rate-limit": "Pranešimų užklausų limitas pagal taisyklę", + "max-calculated-fields": "Didžiausias apskaičiuojamų laukų skaičius vienam subjektui", + "max-calculated-fields-range": "Apskaičiuojamų laukų skaičius negali būti neigiamas", + "max-calculated-fields-required": "Didžiausias apskaičiuojamų laukų skaičius vienam subjektui yra privalomas", + "max-data-points-per-rolling-arg": "Didžiausias duomenų taškų skaičius slenkančiuose argumentuose", + "max-data-points-per-rolling-arg-range": "Didžiausias duomenų taškų skaičius slenkančiuose argumentuose negali būti neigiamas", + "max-data-points-per-rolling-arg-required": "Didžiausias duomenų taškų skaičius slenkančiuose argumentuose yra privalomas", + "max-arguments-per-cf": "Didžiausias argumentų skaičius viename apskaičiuojamame lauke", + "max-arguments-per-cf-range": "Didžiausias argumentų skaičius viename apskaičiuojamame lauke negali būti neigiamas", + "max-arguments-per-cf-required": "Didžiausias argumentų skaičius viename apskaičiuojamame lauke yra privalomas", + "max-state-size": "Didžiausias būsenos dydis (KB)", + "max-state-size-range": "Didžiausias būsenos dydis (KB) negali būti neigiamas", + "max-state-size-required": "Didžiausias būsenos dydis (KB) yra privalomas", + "max-value-argument-size": "Didžiausias vieno argumento reikšmės dydis (KB)", + "max-value-argument-size-range": "Didžiausias vieno argumento reikšmės dydis (KB) negali būti neigiamas", + "max-value-argument-size-required": "Didžiausias vieno argumento reikšmės dydis (KB) yra privalomas", + "max-transport-messages": "Didžiausias transporto pranešimų skaičius", + "max-transport-messages-required": "Didžiausias transporto pranešimų skaičius yra privalomas.", + "max-transport-messages-range": "Didžiausias transporto pranešimų skaičius negali būti neigiamas", + "max-transport-data-points": "Didžiausias transporto duomenų taškų skaičius", + "max-transport-data-points-required": "Didžiausias transporto duomenų taškų skaičius yra privalomas.", + "max-transport-data-points-range": "Didžiausias transporto duomenų taškų skaičius negali būti neigiamas", + "max-r-e-executions": "Didžiausias taisyklių variklio vykdymų skaičius", + "max-r-e-executions-required": "Didžiausias taisyklių variklio vykdymų skaičius yra privalomas.", + "max-r-e-executions-range": "Didžiausias taisyklių variklio vykdymų skaičius negali būti neigiamas", + "max-j-s-executions": "Didžiausias JavaScript vykdymų skaičius", + "max-j-s-executions-required": "Didžiausias JavaScript vykdymų skaičius yra privalomas.", + "max-j-s-executions-range": "Didžiausias JavaScript vykdymų skaičius negali būti neigiamas", + "max-tbel-executions": "Didžiausias TBEL vykdymų skaičius", + "max-tbel-executions-required": "Didžiausias TBEL vykdymų skaičius yra privalomas.", + "max-tbel-executions-range": "Didžiausias TBEL vykdymų skaičius negali būti neigiamas", + "max-d-p-storage-days": "Didžiausias duomenų taškų saugojimo dienų skaičius", + "max-d-p-storage-days-required": "Didžiausias duomenų taškų saugojimo dienų skaičius yra privalomas.", + "max-d-p-storage-days-range": "Didžiausias duomenų taškų saugojimo dienų skaičius negali būti neigiamas", + "default-storage-ttl-days": "Numatytasis duomenų saugojimo laikotarpis (TTL dienomis)", + "default-storage-ttl-days-required": "Numatytasis duomenų saugojimo laikotarpis (TTL) yra privalomas.", + "default-storage-ttl-days-range": "Numatytasis duomenų saugojimo laikotarpis (TTL) negali būti neigiamas", + "alarms-ttl-days": "Pavojaus signalų TTL (dienomis)", + "alarms-ttl-days-required": "Pavojaus signalų TTL (dienomis) yra privalomas", + "alarms-ttl-days-days-range": "Pavojaus signalų TTL (dienomis) negali būti neigiamas", + "rpc-ttl-days": "RPC TTL (dienomis)", + "rpc-ttl-days-required": "RPC TTL (dienomis) yra privalomas", + "rpc-ttl-days-days-range": "RPC TTL (dienomis) negali būti neigiamas", + "queue-stats-ttl-days": "Eilių statistikos TTL (dienomis)", + "queue-stats-ttl-days-required": "Eilių statistikos TTL (dienomis) yra privalomas", + "queue-stats-ttl-days-range": "Eilių statistikos TTL (dienomis) negali būti neigiamas", + "rule-engine-exceptions-ttl-days": "Taisyklių variklio išimčių TTL (dienomis)", + "rule-engine-exceptions-ttl-days-required": "Taisyklių variklio išimčių TTL (dienomis) yra privalomas", + "rule-engine-exceptions-ttl-days-range": "Taisyklių variklio išimčių TTL (dienomis) negali būti neigiamas", + "max-rule-node-executions-per-message": "Didžiausias taisyklių mazgo vykdymų skaičius vienai žinutei", + "max-rule-node-executions-per-message-required": "Didžiausias taisyklių mazgo vykdymų skaičius vienai žinutei yra privalomas.", + "max-rule-node-executions-per-message-range": "Didžiausias taisyklių mazgo vykdymų skaičius vienai žinutei negali būti neigiamas", + "max-emails": "Didžiausias išsiųstų el. laiškų skaičius", + "max-emails-required": "Didžiausias išsiųstų el. laiškų skaičius yra privalomas.", + "max-emails-range": "Didžiausias išsiųstų el. laiškų skaičius negali būti neigiamas", + "sms-enabled": "SMS siuntimas įjungtas", + "max-sms": "Didžiausias išsiųstų SMS skaičius", + "max-sms-required": "Didžiausias išsiųstų SMS skaičius yra privalomas.", + "max-sms-range": "Didžiausias išsiųstų SMS skaičius negali būti neigiamas", + "max-created-alarms": "Didžiausias sukurtų pavojaus signalų skaičius", + "max-created-alarms-required": "Didžiausias sukurtų pavojaus signalų skaičius yra privalomas.", + "max-created-alarms-range": "Didžiausias sukurtų pavojaus signalų skaičius negali būti neigiamas", + "no-queue": "Eilė nesukonfigūruota", + "add-queue": "Pridėti eilę", + "queues-with-count": "Eilės ({{count}})", + "tenant-rest-limits": "REST užklausų limitas nuomininkui", + "customer-rest-limits": "REST užklausų limitas klientui", + "incorrect-pattern-for-rate-limits": "Formatas turi būti kableliais atskirtos poros su talpa ir laikotarpiu (sekundėmis), pvz.: 100:1,2000:60", + "too-small-value-zero": "Reikšmė turi būti didesnė nei 0", + "too-small-value-one": "Reikšmė turi būti didesnė nei 1", + "queue-size-is-limited-by-system-configuration": "Eilės dydis taip pat yra ribojamas sistemos konfigūracijos.", + "cassandra-write-tenant-core-limits-configuration": "REST API Cassandra rašymo užklausos", + "cassandra-read-tenant-core-limits-configuration": "REST API ir WS telemetrijos Cassandra skaitymo užklausos", + "cassandra-write-tenant-rule-engine-limits-configuration": "Taisyklių variklio telemetrijos Cassandra rašymo užklausos", + "cassandra-read-tenant-rule-engine-limits-configuration": "Taisyklių variklio telemetrijos Cassandra skaitymo užklausos", + "ws-limit-max-sessions-per-tenant": "Didžiausias sesijų skaičius vienam nuomininkui", + "ws-limit-max-sessions-per-customer": "Didžiausias sesijų skaičius vienam klientui", + "ws-limit-max-sessions-per-regular-user": "Didžiausias sesijų skaičius vienam vartotojui", + "ws-limit-max-sessions-per-public-user": "Didžiausias sesijų skaičius viešam vartotojui", + "ws-limit-queue-per-session": "Didžiausias pranešimų eilės dydis vienai sesijai", + "ws-limit-max-subscriptions-per-tenant": "Didžiausias prenumeratų skaičius vienam nuomininkui", + "ws-limit-max-subscriptions-per-customer": "Didžiausias prenumeratų skaičius vienam klientui", + "ws-limit-max-subscriptions-per-regular-user": "Didžiausias prenumeratų skaičius vienam vartotojui", + "ws-limit-max-subscriptions-per-public-user": "Didžiausias prenumeratų skaičius viešam vartotojui", + "ws-limit-updates-per-session": "WS atnaujinimų skaičius vienai sesijai", + "rate-limits": { + "add-limit": "Pridėti limitą", + "and-also-less-than": "ir taip pat mažesnis nei", + "advanced-settings": "Pažangūs nustatymai", + "edit-limit": "Redaguoti limitą", + "calculated-field-debug-event-rate-limit": "Apskaičiuojamų laukų derinimo įvykių limitas", + "edit-calculated-field-debug-event-rate-limit": "Redaguoti apskaičiuojamų laukų derinimo įvykių limitus", + "edit-transport-tenant-msg-title": "Redaguoti nuomininko transporto pranešimų limitus", + "edit-transport-tenant-telemetry-msg-title": "Redaguoti nuomininko telemetrijos pranešimų limitus", + "edit-transport-tenant-telemetry-data-points-title": "Redaguoti nuomininko telemetrijos duomenų taškų limitus", + "edit-transport-device-msg-title": "Redaguoti įrenginių transporto pranešimų limitus", + "edit-transport-device-telemetry-msg-title": "Redaguoti įrenginių telemetrijos pranešimų limitus", + "edit-transport-device-telemetry-data-points-title": "Redaguoti įrenginių telemetrijos duomenų taškų limitus", + "edit-transport-gateway-msg-title": "Redaguoti šliuzo pranešimų limitus", + "edit-transport-gateway-telemetry-msg-title": "Redaguoti šliuzo telemetrijos pranešimų limitus", + "edit-transport-gateway-telemetry-data-points-title": "Redaguoti šliuzo telemetrijos duomenų taškų limitus", + "edit-transport-gateway-device-msg-title": "Redaguoti šliuzo įrenginių pranešimų limitus", + "edit-transport-gateway-device-telemetry-msg-title": "Redaguoti šliuzo įrenginių telemetrijos pranešimų limitus", + "edit-transport-gateway-device-telemetry-data-points-title": "Redaguoti šliuzo įrenginių telemetrijos duomenų taškų limitus", + "edit-tenant-rest-limits-title": "Redaguoti REST užklausų limitus nuomininkui", + "edit-customer-rest-limits-title": "Redaguoti REST užklausų limitus klientui", + "edit-ws-limit-updates-per-session-title": "Redaguoti WS atnaujinimų per sesiją limitus", + "edit-cassandra-write-tenant-core-limits-configuration": "Redaguoti REST API Cassandra rašymo užklausų limitus", + "edit-cassandra-read-tenant-core-limits-configuration": "Redaguoti REST API ir WS telemetrijos Cassandra skaitymo užklausų limitus", + "edit-cassandra-write-tenant-rule-engine-limits-configuration": "Redaguoti taisyklių variklio telemetrijos Cassandra rašymo užklausų limitus", + "edit-cassandra-read-tenant-rule-engine-limits-configuration": "Redaguoti taisyklių variklio telemetrijos Cassandra skaitymo užklausų limitus", + "edit-tenant-entity-export-rate-limit-title": "Redaguoti objektų versijų kūrimo limitus", + "edit-tenant-entity-import-rate-limit-title": "Redaguoti objektų versijų įkėlimo limitus", + "edit-tenant-notification-request-rate-limit-title": "Redaguoti pranešimų užklausų limitus", + "edit-tenant-notification-requests-per-rule-rate-limit-title": "Redaguoti pranešimų užklausų limitus pagal taisyklę", + "edit-edge-events-rate-limit": "Redaguoti Edge įvykių limitus", + "edit-edge-events-per-edge-rate-limit": "Redaguoti Edge įvykių limitus pagal Edge įrenginį", + "edge-events-rate-limit": "Edge įvykiai", + "edge-events-per-edge-rate-limit": "Edge įvykiai pagal Edge įrenginį", + "edit-edge-uplink-messages-rate-limit": "Redaguoti Edge į viršų siunčiamų pranešimų (uplink) limitus", + "edit-edge-uplink-messages-per-edge-rate-limit": "Redaguoti Edge į viršų siunčiamų pranešimų (uplink) limitus pagal Edge įrenginį", + "edge-uplink-messages-rate-limit": "Edge uplink pranešimai", + "edge-uplink-messages-per-edge-rate-limit": "Edge uplink pranešimai pagal Edge įrenginį", + "messages-per": "pranešimų per", + "not-set": "Nenustatyta", + "number-of-messages": "Pranešimų skaičius", + "number-of-messages-required": "Pranešimų skaičius yra privalomas.", + "number-of-messages-min": "Mažiausia reikšmė yra 1.", + "preview": "Peržiūra", + "per-seconds": "Per sekundes", + "per-seconds-required": "Laiko norma yra privaloma.", + "per-seconds-min": "Mažiausia reikšmė yra 1.", + "per-seconds-duplicate": "Pasikartojanti laiko norma. Kiekvienas laiko intervalas turi būti unikalus.", + "rate-limits": "Greitaveikos limitai", + "remove-limit": "Pašalinti limitą", + "transport-tenant-msg": "Nuomininko transporto pranešimai", + "transport-tenant-telemetry-msg": "Nuomininko telemetrijos pranešimai", + "transport-tenant-telemetry-data-points": "Nuomininko telemetrijos duomenų taškai", + "transport-device-msg": "Įrenginių transporto pranešimai", + "transport-device-telemetry-msg": "Įrenginių telemetrijos pranešimai", + "transport-device-telemetry-data-points": "Įrenginių telemetrijos duomenų taškai", + "transport-gateway-msg": "Šliuzo pranešimai", + "transport-gateway-telemetry-msg": "Šliuzo telemetrijos pranešimai", + "transport-gateway-telemetry-data-points": "Šliuzo telemetrijos duomenų taškai", + "transport-gateway-device-msg": "Šliuzo įrenginių pranešimai", + "transport-gateway-device-telemetry-msg": "Šliuzo įrenginių telemetrijos pranešimai", + "transport-gateway-device-telemetry-data-points": "Šliuzo įrenginių telemetrijos duomenų taškai", + "sec": "sek." + } + }, + "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": "Išplėstiniai nustatymai", + "custom": "Pasirinktinis", + "predefined": { + "yesterday": "Vakar", + "day-before-yesterday": "Užvakar", + "this-day-last-week": "Ši diena prieš savaitę", + "previous-week": "Praėjusi savaitė (Sek – Šeš)", + "previous-week-iso": "Praėjusi savaitė (Pir – Sek)", + "previous-month": "Praėjęs mėnuo", + "previous-quarter": "Praėjęs ketvirtis", + "previous-half-year": "Praėjęs pusmetis", + "previous-year": "Praėję metai", + "current-hour": "Dabartinė valanda", + "current-day": "Dabartinė diena", + "current-day-so-far": "Dabartinė diena iki dabar", + "current-week": "Dabartinė savaitė (Sek – Šeš)", + "current-week-iso": "Dabartinė savaitė (Pir – Sek)", + "current-week-so-far": "Dabartinė savaitė iki dabar (Sek – Šeš)", + "current-week-iso-so-far": "Dabartinė savaitė iki dabar (Pir – Sek)", + "current-month": "Dabartinis mėnuo", + "current-month-so-far": "Dabartinis mėnuo iki dabar", + "current-quarter": "Dabartinis ketvirtis", + "current-quarter-so-far": "Dabartinis ketvirtis iki dabar", + "current-half-year": "Dabartinis pusmetis", + "current-half-year-so-far": "Dabartinis pusmetis iki dabar", + "current-year": "Dabartiniai metai", + "current-year-so-far": "Dabartiniai metai iki dabar" + }, + "type": { + "week": "Savaitė (Sek – Šeš)", + "week-iso": "Savaitė (Pir – Sek)", + "month": "Mėnuo", + "quarter": "Ketvirtis" + } + }, + "timeunit": { + "milliseconds": "Milisekundės", + "seconds": "Sekundės", + "minutes": "Minutės", + "hours": "Valandos", + "days": "Dienos" + }, + "timewindow": { + "timewindow": "Laiko langas", + "timewindow-settings": "Laiko lango nustatymai", + "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": { + "years": "{ years, plural, =1 {1 metai } other {# metai } }", + "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ė", + "period": "nuo {{ startTime }} iki {{ endTime }}", + "edit": "Redaguoti laiko langą", + "date-range": "Datų intervalas", + "for-all-time": "Visas laikotarpis", + "last": "Paskutinė", + "time-period": "Laikotarpis", + "hide": "Slėpti", + "interval": "Intervalas", + "just-now": "Ką tik", + "just-now-lower": "ką tik", + "ago": "prieš", + "style": "Laiko lango stilius", + "icon": "Piktograma", + "icon-position": "Piktogramos padėtis", + "icon-position-left": "Kairėje", + "icon-position-right": "Dešinėje", + "font": "Šriftas", + "color": "Spalva", + "displayTypePrefix": "Rodyti „Realiu laiku“ / „Istorija“ priešdėlį", + "preview": "Peržiūra", + "relative": "Santykinis", + "range": "Intervalas", + "hide-timewindow-section": "Slėpti laiko lango sekciją nuo naudotojų", + "hide-last-interval": "Slėpti paskutinį intervalą nuo naudotojų", + "hide-relative-interval": "Slėpti santykinį intervalą nuo naudotojų", + "hide-fixed-interval": "Slėpti fiksuotą intervalą nuo naudotojų", + "hide-aggregation": "Slėpti agregaciją nuo naudotojų", + "hide-group-interval": "Slėpti grupavimo intervalą nuo naudotojų", + "hide-max-values": "Slėpti maksimalias reikšmes nuo naudotojų", + "hide-timezone": "Slėpti laiko juostą nuo naudotojų", + "disable-custom-interval": "Išjungti pasirinktinių intervalų pasirinkimą", + "edit-aggregation-functions-list": "Redaguoti agregavimo funkcijų sąrašą", + "edit-aggregation-functions-list-hint": "Galima nurodyti galimų funkcijų sąrašą.", + "allowed-aggregation-functions": "Leidžiamos agregavimo funkcijos", + "edit-intervals-list": "Redaguoti intervalų sąrašą", + "allowed-agg-intervals": "Grupavimo intervalai", + "default-agg-interval": "Numatytasis grupavimo intervalas", + "edit-intervals-list-hint": "Galima nurodyti galimų intervalų parinkčių sąrašą.", + "edit-grouping-intervals-list-hint": "Galima sukonfigūruoti grupavimo intervalų sąrašą ir numatytąjį grupavimo intervalą.", + "all": "Visi" + }, + "tooltip": { + "trigger": "Trigeris", + "trigger-point": "Taškas", + "trigger-axis": "Ašis", + "label": "Etiketė", + "value": "Reikšmė", + "date": "Data", + "show-date-time-interval": "Rodyti datos ir laiko intervalą", + "show-date-time-interval-hint": "Rodyti datos ir laiko intervalą pagal duomenų agregaciją.", + "hide-zero-tooltip-values": "Slėpti nulines reikšmes", + "background-color": "Fono spalva", + "background-blur": "Fono suliejimas" + }, + "unit": { + "set-unit-conversion": "Nustatyti matavimo vienetų konvertavimą", + "unit-settings": { + "unit-settings": "Vienetų nustatymai", + "source-unit": "Pirminis vienetas", + "source-unit-hint": "Tai saugomos reikšmės vienetas. Vienetas, iš kurio konvertuojama. Įveskite simbolį, naudojamą jūsų šaltinio duomenyse (pvz., m, km, ft, in).", + "target-metric-unit": "Tikslinis metrinis vienetas", + "target-metric-unit-hint": "Pasirinkite, į kurį metrinį (SI) vienetą konvertuoti šaltinio reikšmę (pvz., cm, mm, km).", + "target-imperial-unit": "Tikslinis imperinis vienetas", + "target-imperial-unit-hint": "Pasirinkite, į kurį imperinį vienetą konvertuoti šaltinio reikšmę (pvz., in, ft, yd).", + "target-hybrid-unit": "Tikslinis mišrus vienetas", + "target-hybrid-unit-hint": "Pasirinkite, į kurį mišrų vienetą konvertuoti šaltinio reikšmę (pvz., cm, in, km). Mišrūs vienetai jungia metrinius ir/arba imperinius vienetus.", + "enable-unit-conversion": "Įjungti vienetų konvertavimą", + "enable-unit-conversion-hint": "Įjunkite, kad suaktyvintumėte konvertavimą. Kai išjungta – šaltinio reikšmė bus perduodama nekeičiama. Išjungta, jei atitinkamoje matavimo grupėje yra tik vienas vienetas (pvz., šviesos srautas, OKI)." + }, + "unit-system": "Matavimo sistema", + "unit-system-type": { + "AUTO": "Automatinė", + "METRIC": "Metrinė", + "IMPERIAL": "Imperinė", + "HYBRID": "Mišri" + }, + "measures": { + "absorbed-dose-rate": "Sugertos dozės greitis", + "acceleration": "Pagreitis", + "acidity": "Rūgštingumas", + "air-quality-index": "Oro kokybės indeksas", + "amount-of-substance": "Medžiagos kiekis", + "angle": "Kampas", + "angular-acceleration": "Kampinis pagreitis", + "area": "Plotas", + "area-density": "Paviršiaus tankis", + "capacitance": "Talpa", + "catalytic-activity": "Katalizinė veikla", + "catalytic-concentration": "Katalizinė koncentracija", + "charge": "Krovinys", + "current-density": "Srovės tankis", + "data-transfer-rate": "Duomenų perdavimo greitis", + "density": "Tankis", + "digital": "Skaitmeninis", + "dimension-ratio": "Matmenų santykis", + "dynamic-viscosity": "Dinaminė klampa", + "earthquake-magnitude": "Žemės drebėjimo stiprumas", + "electric-charge-density": "Elektros krūvio tankis", + "electric-current": "Elektros srovė", + "electric-dipole-moment": "Elektrinis dipolio momentas", + "electric-field-strength": "Elektrinio lauko stipris", + "electric-flux": "Elektrinis srautas", + "electric-permittivity": "Elektrinė skvarba", + "electric-polarizability": "Elektrinė poliariškumas", + "electrical-conductance": "Elektrinė laidumo varža", + "electrical-conductivity": "Elektrinis laidumas", + "energy": "Energija", + "energy-density": "Energijos tankis", + "force": "Jėga", + "frequency": "Dažnis", + "fuel-efficiency": "Kuro sąnaudos / efektyvumas", + "heat-capacity": "Šiluminė talpa", + "illuminance": "Apšvietis", + "inductance": "Induktyvumas", + "kinematic-viscosity": "Kinematinė klampa", + "length": "Ilgis", + "light-exposure": "Šviesos ekspozicija", + "linear-charge-density": "Linijinis krūvio tankis", + "logarithmic-ratio": "Logaritminis santykis", + "luminous-efficacy": "Šviesos efektyvumas", + "luminous-flux": "Šviesos srautas", + "luminous-intensity": "Šviesos stipris", + "magnetic-field-gradient": "Magnetinio lauko gradientas", + "magnetic-flux": "Magnetinis srautas", + "magnetic-flux-density": "Magnetinio srauto tankis", + "magnetic-moment": "Magnetinis momentas", + "magnetic-permeability": "Magnetinė skvarba", + "mass": "Masa", + "mass-fraction": "Masės dalis", + "molar-concentration": "Molinė koncentracija", + "molar-energy": "Molinė energija", + "molar-heat-capacity": "Molinė šiluminė talpa", + "molar-mass": "Molinė masė", + "number-concentration": "Dalelių koncentracija", + "parts-per-million": "Dalinės dalys milijone (ppm)", + "power": "Galia", + "power-density": "Galios tankis", + "pressure": "Slėgis", + "radiance": "Spinduliuotė", + "radiant-intensity": "Spinduliuotės intensyvumas", + "radiation-dose": "Radiacijos dozė", + "radioactive-decay": "Radioaktyvus skilimas", + "radioactivity": "Radioaktyvumas", + "radioactivity-concentration": "Radioaktyvumo koncentracija", + "reciprocal-length": "Atvirkštinis ilgis", + "resistance": "Varža", + "reynolds-number": "Reinoldso skaičius", + "signal-level": "Signalo lygis", + "solid-angle": "Erdvinis kampas", + "specific-energy": "Specifinė energija", + "specific-heat-capacity": "Specifinė šiluminė talpa", + "specific-humidity": "Specifinė drėgmė", + "specific-volume": "Specifinis tūris", + "speed": "Greičio", + "surface-charge-density": "Paviršiaus krūvio tankis", + "surface-tension": "Paviršiaus įtempimas", + "temperature": "Temperatūra", + "thermal-conductivity": "Šilumos laidumas", + "time": "Laikas", + "torque": "Sukimo momentas", + "turbidity": "Drumstumas", + "voltage": "Įtampa", + "volume": "Tūris", + "volume-flow": "Tūrio srautas" + }, + "millimeter": "Milimetras", + "centimeter": "Centimetras", + "decimeter": "Decimetras", + "angstrom": "Angstromas", + "nanometer": "Nanometras", + "micrometer": "Mikrometras", + "meter": "Metras", + "kilometer": "Kilometras", + "inch": "Colis", + "foot": "Pėda", + "foot-us": "Pėda (JAV matavimas)", + "yard": "Jardas", + "mile": "Mylia", + "nautical-mile": "Jūrmylė", + "astronomical-unit": "Astronominis vienetas", + "reciprocal-metre": "Atvirkštinis metras", + "meter-per-meter": "Metrai vienam metrui", + "steradian": "Steradianas", + "thou": "Tausas", + "barleycorn": "Miežio grūdas", + "hand": "Rankos ilgis", + "chain": "Grandinė", + "furlong": "Furlongas", + "league": "Lygos vienetas", + "fathom": "Sazenas", + "cable": "Kabelis", + "link": "Nuoroda", + "rod": "Lazda", + "nanogram": "Nanogramas", + "microgram": "Mikrogramas", + "milligram": "Miligramas", + "gram": "Gramas", + "kilogram": "Kilogramas", + "tonne": "Tona", + "ounce": "Uncija", + "pound": "Svaras", + "stone": "Akmuo (matavimo vienetas)", + "hundredweight-count": "Svorinė šimtinė", + "short-tons": "Trumpa tona", + "dalton": "Daltonas", + "grain": "Grūdas", + "drachm": "Drachma", + "quarter": "Ketvirtis", + "slug": "Slugas", + "carat": "Karat", + "cubic-millimeter": "Kubinis milimetras", + "cubic-centimeter": "Kubinis centimetras", + "cubic-meter": "Kubinis metras", + "cubic-kilometer": "Kubinis kilometras", + "microliter": "Mikrolitras", + "milliliter": "Mililitras", + "liter": "Litras", + "hectoliter": "Hektolitras", + "cubic-inch": "Kubinis colis", + "cubic-foot": "Kubinė pėda", + "cubic-yard": "Kubinis jardas", + "fluid-ounce": "Skysčio uncija", + "fluid-ounce-per-second": "Skysčio uncija per sekundę", + "pint": "Pinta", + "quart": "Kvorta", + "gallon": "Galonas", + "oil-barrels": "Naftos statinė", + "cubic-meter-per-kilogram": "Kubinis metras kilogramui", + "gill": "Gilas", + "hogshead": "Statinė (Hogshead)", + "teaspoon": "Arbatinis šaukštelis", + "tablespoon": "Valgomasis šaukštas", + "cup": "Puodelis", + "celsius": "Celsijus", + "kelvin": "Kelvinas", + "rankine": "Rankinas", + "fahrenheit": "Farenheitas", + "percent": "Procentas", + "meter-per-second": "Metrai per sekundę", + "kilometer-per-hour": "Kilometrai per valandą", + "foot-per-second": "Pėdos per sekundę", + "foot-per-minute": "Pėdos per minutę", + "mile-per-hour": "Mylios per valandą", + "knot": "Mazgas", + "inch-per-second": "Coliai per sekundę", + "inch-per-hour": "Coliai per valandą", + "millimeters-per-minute": "Milimetrai per minutę", + "meter-per-minute": "Metrai per minutę", + "kilometer-per-hour-squared": "Kilometrai per valandą kvadratu", + "foot-per-second-squared": "Pėdos per sekundę kvadratu", + "pascal": "Paskalis", + "kilopascal": "Kilopaskalis", + "megapascal": "Megapaskalis", + "gigapascal": "Gigapaskalis", + "millibar": "Milibaras", + "bar": "Baras", + "kilobar": "Kilobaras", + "newton": "Niutonas", + "newton-meter": "Niutono metras", + "foot-pounds": "Pėdos-svarai", + "inch-pounds": "Colio-svarai", + "newton-per-meter": "Niutonas metrui", + "atmospheres": "Atmosferos", + "pounds-per-square-inch": "Svarai kvadratiniam coliui", + "kilopound-per-square-inch": "Kilosvarai kvadratiniam coliui", + "torr": "Torras", + "inches-of-mercury": "Gyvsidabrio coliai", + "pascal-per-square-meter": "Paskalis kvadratiniam metrui", + "pound-per-square-inch": "Svoris kvadratiniam coliui", + "newton-per-square-meter": "Niutonas kvadratiniam metrui", + "kilogram-force-per-square-meter": "Kilogramo jėga kvadratiniam metrui", + "pascal-per-square-centimeter": "Paskalis kvadratiniam centimetrui", + "ton-force-per-square-inch": "Tonos jėga kvadratiniam coliui", + "kilonewton-per-square-meter": "Kiloniutonas kvadratiniam metrui", + "newton-per-square-millimeter": "Niutonas kvadratiniam milimetrui", + "microjoule": "Mikrodžiaulis", + "millijoule": "Milidžiaulis", + "joule": "Džiaulis", + "kilojoule": "Kilodžiaulis", + "megajoule": "Megadžiaulis", + "gigajoule": "Gigadžiaulis", + "watt-hour": "Vatvalandė", + "watt-minute": "Vatminutė", + "kilowatt-hour": "Kilovatvalandė", + "milliwatt-hour": "Milivatvalandė", + "megawatt-hour": "Megavatvalandė", + "gigawatt-hour": "Gigavatvalandė", + "electron-volts": "Elektronvoltai", + "joules-per-coulomb": "Džiauliai kulonui", + "british-thermal-unit": "Britų šiluminis vienetas (BTU)", + "thousand-british-thermal-unit": "Tūkstantis britų šiluminių vienetų", + "million-british-thermal-unit": "Milijonas britų šiluminių vienetų", + "foot-pound": "Pėdos-svaras", + "calorie": "Kalorija", + "small-calorie": "Mažoji kalorija", + "kilocalorie": "Kilokalorija", + "joule-per-kelvin": "Džiaulis kelvinui", + "joule-per-kilogram-kelvin": "Džiaulis kilogramui-kelvinui", + "joule-per-kilogram": "Džiaulis kilogramui", + "watt-per-meter-kelvin": "Vatas metrui-kelvinui", + "joule-per-cubic-meter": "Džiaulis kubiniam metrui", + "therm": "Termas", + "electric-dipole-moment": "Elektrinis dipolio momentas", + "magnetic-dipole-moment": "Magnetinis dipolio momentas", + "debye": "Debajus", + "coulomb-per-square-meter-per-volt": "Kulonas kvadratiniam metrui voltui", + "milliwatt": "Miliwatas", + "microwatt": "Mikrovatas", + "watt": "Vatas", + "kilowatt": "Kilovatas", + "megawatt": "Megavatas", + "gigawatt": "Gigavatas", + "metric-horsepower": "Metinė arklio galia", + "milliwatt-per-square-centimeter": "Milivatai kvadratiniam centimetrui", + "watt-per-square-centimeter": "Vatai kvadratiniam centimetrui", + "kilowatt-per-square-centimeter": "Kilovatai kvadratiniam centimetrui", + "milliwatt-per-square-meter": "Milivatai kvadratiniam metrui", + "watt-per-square-meter": "Vatai kvadratiniam metrui", + "kilowatt-per-square-meter": "Kilovatai kvadratiniam metrui", + "watt-per-square-inch": "Vatai kvadratiniam coliui", + "kilowatt-per-square-inch": "Kilovatai kvadratiniam coliui", + "horsepower": "Arklio galia", + "btu-per-hour": "Britų šiluminiai vienetai per valandą", + "btu-per-second": "Britų šiluminiai vienetai per sekundę", + "btu-per-day": "Britų šiluminiai vienetai per dieną", + "mbtu-per-hour": "Tūkstantis britų šiluminių vienetų per valandą", + "mbtu-per-second": "Tūkstantis britų šiluminių vienetų per sekundę", + "mbtu-per-day": "Tūkstantis britų šiluminių vienetų per dieną", + "mmbtu-per-hour": "Milijonas britų šiluminių vienetų per valandą", + "mmbtu-per-second": "Milijonas britų šiluminių vienetų per sekundę", + "mmbtu-per-day": "Milijonas britų šiluminių vienetų per dieną", + "foot-pound-per-second": "Pėdos-svarai per sekundę", + "coulomb": "Kulonas", + "millicoulomb": "Milikulonas", + "microcoulomb": "Mikrokulonas", + "nanocoulomb": "Nanokulonas", + "picocoulomb": "Pikokulonas", + "coulomb-per-meter": "Kulonai metrui", + "coulomb-per-cubic-meter": "Kulonai kubiniam metrui", + "coulomb-per-square-meter": "Kulonai kvadratiniam metrui", + "square-millimeter": "Kvadratinis milimetras", + "square-centimeter": "Kvadratinis centimetras", + "square-meter": "Kvadratinis metras", + "hectare": "Hektaras", + "square-kilometer": "Kvadratinis kilometras", + "square-inch": "Kvadratinis colis", + "square-foot": "Kvadratinė pėda", + "square-yard": "Kvadratinis jardas", + "acre": "Akras", + "square-mile": "Kvadratinė mylia", + "are": "Aras", + "barn": "Barnas", + "circular-inch": "Apskritiminis colis", + "milliampere-hour": "Miliampervalandė", + "ampere-hours": "Ampervalandės", + "kiloampere-hours": "Kiloampervalandės", + "nanoampere": "Nanoamperas", + "picoampere": "Pikoamperas", + "microampere": "Mikroamperas", + "milliampere": "Miliamperas", + "ampere": "Amperas", + "kiloampere": "Kiloamperas", + "megaampere": "Megaamperas", + "gigaampere": "Gigaamperas", + "microampere-per-square-centimeter": "Mikroamperai kvadratiniam centimetrui", + "ampere-per-square-meter": "Amperai kvadratiniam metrui", + "ampere-per-meter": "Amperai metrui", + "oersted": "Erstedas", + "bohr-magneton": "Boro magnetonas", + "ampere-meter-squared": "Ampermetrai kvadratu", + "nanovolt": "Nanovoltas", + "picovolt": "Pikovoltas", + "millivolt": "Milivoltas", + "microvolt": "Mikrovoltas", + "volt": "Voltas", + "kilovolt": "Kilovoltas", + "megavolt": "Megavoltas", + "dbmV": "dBmV", + "dbm": "Decibelmilivatai", + "volt-meter": "Voltmetras", + "kilovolt-meter": "Kilovoltmetras", + "megavolt-meter": "Megavoltmetras", + "microvolt-meter": "Mikrovoltmetras", + "millivolt-meter": "Milivoltmetras", + "nanovolt-meter": "Nanovoltmetras", + "ohm": "Omas", + "microohm": "Mikroomas", + "milliohm": "Milioomas", + "kilohm": "Kiloomas", + "megohm": "Megaomas", + "gigohm": "Gigaomas", + "millihertz": "Milihercas", + "hertz": "Hercas", + "kilohertz": "Kilohercas", + "megahertz": "Megahercas", + "gigahertz": "Gigahercas", + "terahertz": "Terahercas", + "rpm": "Apsisukimai per minutę", + "candela-per-square-meter": "Kandela kvadratiniam metrui", + "candela": "Kandela", + "lumen": "Liumenas", + "lux": "Liuksas", + "foot-candle": "Pėdos žvakė", + "lumen-per-square-meter": "Liumenai kvadratiniam metrui", + "lux-second": "Liuksas sekundei", + "lumen-second": "Liumenas sekundei", + "lumens-per-watt": "Liumenai vatui", + "mole": "Molis", + "nanomole": "Nanomolis", + "micromole": "Mikromolis", + "millimole": "Milimolis", + "kilomole": "Kilomolis", + "mole-per-cubic-meter": "Moliai kubiniam metrui", + "rssi": "RSSI (signalo stiprumas)", + "ppm": "Dalis milijone (ppm)", + "ppb": "Dalis milijarde (ppb)", + "micrograms-per-cubic-meter": "Mikrogramai kubiniam metrui", + "aqi": "Oro kokybės indeksas (OKI)", + "gram-per-cubic-meter": "Gramai kubiniam metrui", + "gram-per-kilogram": "Specifinis drėgnis (g/kg)", + "millimeters-per-second": "Milimetrai per sekundę", + "neper": "Neperas", + "bel": "Belas", + "decibel": "Decibelas", + "meters-per-second-squared": "Metrai per sekundę kvadratu", + "becquerel": "Bekerelis", + "curie": "Kiuris", + "gray": "Grėjus", + "sievert": "Zyvertas", + "roentgen": "Rentgenas", + "cps": "Impulsai per sekundę", + "rad": "Radas", + "rem": "Remas", + "dps": "Skaidymasis per sekundę", + "rutherford": "Ruterfordas", + "coulombs-per-kilogram": "Kulonai kilogramui", + "becquerels-per-cubic-meter": "Bekereliai kubiniam metrui", + "curies-per-liter": "Kiuriai litrui", + "becquerels-per-second": "Bekereliai per sekundę", + "curies-per-second": "Kiuriai per sekundę", + "gy-per-second": "Grėjai per sekundę", + "watt-per-steradian": "Vatai steradianui", + "watt-per-square-metre-steradian": "Vatai kvadratiniam metrui-steradianui", + "ph-level": "pH lygis", + "turbidity": "Drumstumas", + "mg-per-liter": "Miligramai litrui", + "microsiemens-per-centimeter": "Mikrosiemensai centimetrui", + "millisiemens-per-meter": "Milisiemensai metrui", + "siemens-per-meter": "Siemensai metrui", + "kilogram-per-cubic-meter": "Kilogramai kubiniam metrui", + "gram-per-cubic-centimeter": "Gramai kubiniam centimetrui", + "kilogram-per-square-meter": "Kilogramai kvadratiniam metrui", + "milligram-per-milliliter": "Miligramai mililitrui", + "milligram-per-cubic-meter": "Miligramai kubiniam metrui", + "pound-per-cubic-foot": "Svarai kubinei pėdai", + "ounces-per-cubic-inch": "Uncijos kubiniam coliui", + "tons-per-cubic-yard": "Tonos kubiniam jardui", + "particle-density": "Dalinė tankio vertė", + "kilometers-per-liter": "Kilometrai litrui", + "miles-per-gallon": "Mylios galonui", + "liters-per-100-km": "Litrai 100 kilometrų", + "gallons-per-mile": "Galonai myliai", + "liters-per-hour": "Litrai per valandą", + "gallons-per-hour": "Galonai per valandą", + "beats-per-minute": "Dūžiai per minutę", + "millimeters-of-mercury": "Gyvsidabrio milimetrai", + "milligrams-per-deciliter": "Miligramai decilitrui", + "g-force": "G-jėga", + "kilonewton": "Kiloniutonas", + "kilogram-force": "Kilogramo jėga", + "pound-force": "Svarinė jėga", + "kilopound-force": "Kilosvarinė jėga", + "dyne": "Dina", + "poundal": "Paundalas", + "kip": "Kipas", + "gal": "Galas", + "gravity": "Gravitacija", + "hectopascal": "Hektopaskalis", + "atmosphere": "Atmosfera", + "millibars": "Milibarai", + "inch-of-mercury": "Gyvsidabrio colis", + "richter-scale": "Richtėro skalė", + "nanosecond": "Nanosekundė", + "microsecond": "Mikrosekundė", + "millisecond": "Milisekundė", + "second": "Sekundė", + "minute": "Minutė", + "hour": "Valanda", + "day": "Diena", + "week": "Savaitė", + "month": "Mėnuo", + "year": "Metai", + "cubic-foot-per-minute": "Kubinės pėdos per minutę", + "cubic-meters-per-hour": "Kubiniai metrai per valandą", + "cubic-meters-per-second": "Kubiniai metrai per sekundę", + "liter-per-second": "Litrai per sekundę", + "liter-per-minute": "Litrai per minutę", + "gallons-per-minute": "Galonai per minutę", + "cubic-foot-per-second": "Kubinė pėda per sekundę", + "milliliters-per-minute": "Mililitrai per minutę", + "cubic-decimeter-per-second": "Kubinis decimetras per sekundę", + "bit": "Bitas", + "byte": "Baitas", + "kilobyte": "Kilobaitas", + "megabyte": "Megabaitas", + "gigabyte": "Gigabaitas", + "terabyte": "Terabaitas", + "petabyte": "Petabaitas", + "exabyte": "Eksabaitas", + "zettabyte": "Zetabaitas", + "yottabyte": "Jotabaitas", + "bit-per-second": "Bitai per sekundę", + "kilobit-per-second": "Kilobitai per sekundę", + "megabit-per-second": "Megabitai per sekundę", + "gigabit-per-second": "Gigabitai per sekundę", + "terabit-per-second": "Terabitai per sekundę", + "byte-per-second": "Baitai per sekundę", + "kilobyte-per-second": "Kilobaitai per sekundę", + "megabyte-per-second": "Megabaitai per sekundę", + "gigabyte-per-second": "Gigabaitai per sekundę", + "degree": "Laipsnis", + "radian": "Radianas", + "gradian": "Gradianas", + "arcminute": "Kampinė minutė", + "arcsecond": "Kampinė sekundė", + "milliradian": "Miliradianas", + "revolution": "Apsisukimas", + "siemens": "Siemensas", + "millisiemens": "Milisiemensas", + "microsiemens": "Mikrosiemensas", + "kilosiemens": "Kilosiemensas", + "megasiemens": "Megasiemensas", + "gigasiemens": "Gigasiemensas", + "farad": "Faradas", + "millifarad": "Milifaradas", + "microfarad": "Mikrofaradas", + "nanofarad": "Nanofaradas", + "picofarad": "Pikofaradas", + "kilofarad": "Kilofaradas", + "megafarad": "Megafaradas", + "gigafarad": "Gigafaradas", + "terfarad": "Terafaradas", + "farad-per-meter": "Faradai metrui", + "tesla": "Tesla", + "gauss": "Gausas", + "kilogauss": "Kilogausas", + "millitesla": "Militesla", + "microtesla": "Mikrotesla", + "nanotesla": "Nanotesla", + "kilotesla": "Kilotesla", + "megatesla": "Megatesla", + "millitesla-square-meters": "Militeslos kvadratiniai metrai", + "gamma": "Gama", + "lambda": "Lambda", + "square-meter-per-second": "Kvadratiniai metrai per sekundę", + "square-centimeter-per-second": "Kvadratiniai centimetrai per sekundę", + "stoke": "Stokas", + "centistokes": "Centistokai", + "square-foot-per-second": "Kvadratinės pėdos per sekundę", + "square-inch-per-second": "Kvadratiniai coliai per sekundę", + "pascal-second": "Paskalų sekundė", + "centipoise": "CentiPoisas", + "poise": "Poisas", + "reynolds": "Reinoldso skaičius", + "pound-per-foot-hour": "Svarai pėdai per valandą", + "newton-second-per-square-meter": "Niutono sekundė kvadratiniam metrui", + "dyne-second-per-square-centimeter": "Dinos sekundė kvadratiniam centimetrui", + "kilogram-per-meter-second": "Kilogramai metrui per sekundę", + "tesla-square-meters": "Teslos kvadratiniai metrai", + "maxwell": "Maksvelas", + "tesla-per-meter": "Teslos metrui", + "gauss-per-centimeter": "Gausai centimetrui", + "weber": "Veberis", + "microweber": "Mikroveberis", + "milliweber": "Miliveberis", + "gauss-square-centimeter": "Gausai kvadratiniam centimetrui", + "kilogauss-square-centimeter": "Kilogausai kvadratiniam centimetrui", + "henry": "Henris", + "millihenry": "Milihenris", + "microhenry": "Mikrohenris", + "nanohenry": "Nanohenris", + "henry-per-meter": "Henriai metrui", + "tesla-meter-per-ampere": "Teslos metras amperui", + "gauss-per-oersted": "Gausai erstedui", + "kilogram-per-mole": "Kilogramai moliui", + "gram-per-mole": "Gramai moliui", + "milligram-per-mole": "Miligramai moliui", + "joule-per-mole": "Džiauliai moliui", + "joule-per-mole-kelvin": "Džiauliai moliui-kelvinui", + "millivolts-per-meter": "Milivoltai metrui", + "volts-per-meter": "Voltai metrui", + "kilovolts-per-meter": "Kilovoltai metrui", + "radian-per-second": "Radianai per sekundę", + "radian-per-second-squared": "Radianai per sekundę kvadratu", + "revolutions-per-minute-per-second": "Apsisukimai per minutę per sekundę", + "deg-per-second": "laipsn./s", + "rotation-per-minute": "Apsisukimai per minutę", + "degrees-brix": "Brix laipsniai", + "katal": "Katalis", + "katal-per-cubic-metre": "Kataliai kubiniam metrui", + "paris-inch": "Paryžiaus colis" + }, + "user": { + "user": "Vartotojas", + "users": "Vartotojai", + "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-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 vartotoją} 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 jais susijusi informacija bus panaikinta.", + "activation-email-sent-message": "Aktyvinimo el. laiškas sėkmingai išsiųstas!", + "resend-activation": "Iš naujo siųsti aktyvavimo laišką", + "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": "Norėdami aktyvuoti vartotoją, paspauskite nuorodą:", + "copy-activation-link": "Kopijuoti aktyvavimo nuorodą", + "activation-link-copied-message": "Vartotojo aktyvavimo nuoroda nukopijuota į iškarpinę", + "details": "Informacija", + "login-as-tenant-admin": "Prisijungti kaip valdytojo administratorius", + "login-as-customer-user": "Prisijungti kaip kliento vartotojas", + "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 nukopijuotas į 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": "Versijų valdymas", + "management": "Versijų valdymo administravimas", + "search": "Ieškoti versijų", + "branch": "Šaka", + "default": "Numatytoji", + "select-branch": "Pasirinkite šaką", + "branch-required": "Šaka būtina", + "create-entity-version": "Sukurti subjekto versiją", + "version-name": "Versijos pavadinimas", + "version-name-required": "Versijos pavadinimas būtinas", + "author": "Autorius", + "export-relations": "Eksportuoti ryšius", + "export-attributes": "Eksportuoti atributus", + "export-credentials": "Eksportuoti įgaliojimus", + "export-calculated-fields": "Eksportuoti skaičiuojamus laukus", + "entity-versions": "Subjektų versijos", + "versions": "Versijos", + "created-time": "Sukūrimo laikas", + "version-id": "Versijos ID", + "no-entity-versions-text": "Subjektų versijų nerasta", + "no-versions-text": "Versijų nerasta", + "copy-full-version-id": "Kopijuoti pilną versijos ID", + "create-version": "Sukurti versiją", + "creating-version": "Kuriama versija... Prašome palaukti", + "nothing-to-commit": "Nėra pakeitimų, kuriuos galima įkelti", + "restore-version": "Atkurti versiją", + "restore-entity-from-version": "Atkurti subjektą iš versijos '{{versionName}}'", + "restoring-entity-version": "Atkuriama subjekto versija... Prašome palaukti", + "load-relations": "Įkelti ryšius", + "load-attributes": "Įkelti atributus", + "load-credentials": "Įkelti įgaliojimus", + "load-calculated-fields": "Įkelti skaičiuojamus laukus", + "compare-with-current": "Palyginti su dabartine", + "diff-entity-with-version": "Palyginti su subjekto versija '{{versionName}}'", + "previous-difference": "Ankstesnis skirtumas", + "next-difference": "Kitas skirtumas", + "current": "Dabartinė", + "differences": "{ count, plural, =1 {1 skirtumas} other {# skirtumai} }", + "create-entities-version": "Sukurti subjektų versiją", + "default-sync-strategy": "Numatytoji sinchronizavimo strategija", + "sync-strategy-merge": "Sujungti (Merge)", + "sync-strategy-overwrite": "Perrašyti (Overwrite)", + "entities-to-export": "Subjektai eksportui", + "entities-to-restore": "Subjektai atkūrimui", + "sync-strategy": "Sinchronizavimo strategija", + "all-entities": "Visi subjektai", + "no-entities-to-export-prompt": "Nurodykite subjektus, kuriuos norite eksportuoti", + "no-entities-to-restore-prompt": "Nurodykite subjektus, kuriuos norite atkurti", + "add-entity-type": "Pridėti subjekto tipą", + "remove-all": "Pašalinti visus", + "version-create-result": "{ added, plural, =0 {Subjektų nepridėta} =1 {1 subjektas pridėtas} other {# subjektai pridėti} }.
    { modified, plural, =0 {Neatnaujinta nė vieno subjekto} =1 {1 subjektas atnaujintas} other {# subjektai atnaujinti} }.
    { removed, plural, =0 {Neištrinta nė vieno subjekto} =1 {1 subjektas pašalintas} other {# subjektai pašalinti} }.", + "remove-other-entities": "Pašalinti kitus subjektus", + "find-existing-entity-by-name": "Rasti esamą subjektą pagal pavadinimą", + "restore-entities-from-version": "Atkurti subjektus iš versijos '{{versionName}}'", + "restoring-entities-from-version": "Atkuriami subjektai... Prašome palaukti", + "no-entities-restored": "Neatkurtas nė vienas subjektas", + "created": "{{created}} sukurta", + "updated": "{{updated}} atnaujinta", + "deleted": "{{deleted}} ištrinta", + "remove-other-entities-confirm-text": "Būkite atsargūs! Tai negrįžtamai ištrins visus dabartinius subjektus,
    kurių nėra atkuriamoje versijoje.

    Įveskite „remove other entities“, kad patvirtintumėte.", + "auto-commit-to-branch": "automatiškai įkelti į šaką {{ branch }}", + "default-create-entity-version-name": "{{entityName}} atnaujinimas", + "sync-strategy-merge-hint": "Sukuria arba atnaujina pasirinktus subjektus saugykloje. Kiti saugyklos subjektai neliečiami.", + "sync-strategy-overwrite-hint": "Sukuria arba atnaujina pasirinktus subjektus saugykloje. Visi kiti saugyklos subjektai bus ištrinti.", + "device-credentials-conflict": "Nepavyko įkelti įrenginio su išoriniu ID {{entityId}}
    nes tie patys įgaliojimai jau egzistuoja kitam įrenginiui duomenų bazėje.
    Apsvarstykite galimybę išjungti įkelti įgaliojimus nustatymą atkūrimo formoje.", + "missing-referenced-entity": "Nepavyko įkelti {{sourceEntityTypeName}} su išoriniu ID {{sourceEntityId}}
    nes jis nurodo neegzistuojantį {{targetEntityTypeName}} su ID {{targetEntityId}}.", + "runtime-failed": "Klaida: {{message}}", + "auto-commit-settings-read-only-hint": "Automatinio įkėlimo funkcija neveikia, kai saugykla nustatyta tik skaitymui.", + "rollback-on-error": "Atšaukti pakeitimus klaidos atveju", + "rollback-on-error-hint": "Jei atkuriate didelį subjektų kiekį, galite išjungti šią parinktį našumui padidinti.\nAtminkite: jei atkūrimo metu įvyks klaida, jau išsaugoti subjektai (su ryšiais, atributais ir kt.) liks nepakitę." + }, + "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", + "confirm-to-exit-editor-html": "Turite neišsaugotų valdiklio nustatymų.
    Ar tikrai norite palikti šį puslapį?", + "widget-type-not-found": "Įkeliant valdiklio konfigūraciją įvyko klaida.
    Gali būti, kad šis valdiklio tipas buvo pašalintas.", + "widget-type-load-error": "Valdiklis neįkeltas dėl klaidų:", + "remove": "Panaikinti valdiklį", + "delete": "Panaikinti valdiklį", + "edit": "Redaguoti valdiklį", + "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.", + "replace-reference-with-widget-copy": "Pakeisti nuorodą valdiklio kopija", + "timeseries": "Telemetrija", + "search-data": "Duomenų paieška", + "no-data-found": "Duomenų nerasta", + "latest": "Naujausios reikšmės", + "rpc": "Valdymo valdiklis", + "alarm": "Įspėjimų valdiklis", + "static": "Statinis valdiklis", + "timeseries-short": "telemetrija", + "latest-short": "naujausios", + "rpc-short": "valdymas", + "alarm-short": "įspėjimas", + "static-short": "statinis", + "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 nepavyko! Yra klaidų konfigūracijoje.", + "save": "Išsaugoti valdiklį", + "saveAs": "Išsaugoti valdiklį kaip", + "move": "Perkelti valdiklį", + "save-widget-as": "Išsaugoti valdiklį kaip", + "save-widget-as-text": "Įveskite naują valdiklio pavadinimą", + "toggle-fullscreen": "Perjungti į viso ekrano režimą", + "run": "Paleisti valdiklį", + "widget-title": "Valdiklio 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-extension": "Yra plėtinys", + "remove-resource": "Pašalinti resursą", + "add-resource": "Pridėti resursą", + "html": "HTML", + "tidy": "Tvarkyti (Tidy)", + "css": "CSS", + "settings-form": "Nustatymų forma", + "data-key-settings-form": "Duomenų rakto nustatymų forma", + "latest-data-key-settings-form": "Naujausių duomenų rakto nustatymų forma", + "widget-settings": "Valdiklio nustatymai", + "description": "Aprašymas", + "tags": "Žymos", + "image-preview": "Paveiksliuko peržiūra", + "settings-form-selector": "Nustatymų formos pasirinkiklis", + "data-key-settings-form-selector": "Duomenų rakto nustatymų formos pasirinkiklis", + "latest-data-key-settings-form-selector": "Naujausių duomenų rakto nustatymų formos pasirinkiklis", + "all": "Visi", + "actual": "Esamas", + "scada": "SCADA simbolis", + "deprecated": "Pasenęs", + "has-basic-mode": "Turi bazinį režimą", + "basic-mode-form-selector": "Bazinio režimo formos pasirinkiklis", + "basic-mode": "Bazinė versija", + "advanced-mode": "Pažangi versija", + "javascript": "JavaScript", + "js": "JS", + "delete-widget-title": "Ar tikrai norite panaikinti valdiklį '{{widgetName}}'?", + "delete-widget-text": "Po patvirtinimo valdiklis ir visa susijusi informacija bus negrįžtamai pašalinti.", + "delete-widgets-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 valdiklį} other {# valdiklius} }?", + "delete-widgets-text": "Būkite dėmesingi — po patvirtinimo visi pasirinkti valdikliai ir susijusi informacija bus negrįžtamai pašalinta.", + "delete-widget": "Panaikinti valdiklį", + "widget-template-load-failed-error": "Nepavyko įkelti valdiklio šablono!", + "details": "Informacija", + "widget-details": "Informacija apie valdiklį", + "add": "Pridėti valdiklį", + "add-existing-widget": "Pridėti esamą valdiklį", + "add-new-widget": "Pridėti naują valdiklį", + "search-widgets": "Valdiklių paieška", + "selected-widgets": "Pasirinkta { count, plural, =1 {1 valdiklis} other {# valdikliai} }", + "undo": "Atšaukti valdiklio pakeitimus", + "export": "Eksportuoti valdiklį", + "export-prompt": "Įtraukti valdiklio paveikslėlius ir resursus", + "export-widgets": "Eksportuoti valdiklius", + "export-widgets-prompt": "Įtraukti valdiklių paveikslėlius ir resursus", + "import": "Importuoti valdiklį", + "no-data": "Nėra duomenų atvaizdavimui", + "data-overflow": "Valdiklyje rodoma {{count}} iš {{total}} įrašų", + "alarm-data-overflow": "Valdiklyje rodomi {{allowedEntities}} (maksimalus leistinas skaičius) įspėjimų įrašai iš {{totalEntities}}", + "search": "Valdiklių paieška", + "filter": "Valdiklių filtro tipas", + "loading-widgets": "Įkeliami valdikliai...", + "widget-template-error": "Netinkamas valdiklio HTML šablonas.", + "reference": "Nuoroda" + }, + "widget-action": { + "header-button": "Valdiklio antraštės mygtukas", + "do-nothing": "Nedaryti nieko", + "open-dashboard-state": "Eiti į naują skydelio būseną", + "update-dashboard-state": "Atnaujinti dabartinę skydelio būseną", + "open-dashboard": "Eiti į kitą skydelį", + "custom": "Aprašomas veiksmas", + "custom-pretty": "Aprašomas veiksmas (su HTML šablonu)", + "custom-pretty-error-title": "Klaida vykdant pasirinktą dialogą", + "custom-pretty-template-error": "Netinkamas pasirinktinio dialogo šablonas.", + "custom-pretty-controller-error": "Įvyko klaida vykdant pasirinktinio dialogo funkciją.", + "mobile-action": "Mobilusis veiksmas", + "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", + "select-target-dashboard": "Pasirinkite tikslinį skydelį", + "target-dashboard-required": "Tikslinis skydelis būtinas.", + "open-right-layout": "Atidaryti tinkamą skydelio išdėstymą (mobilusis vaizdas)", + "state-display-type": "Skydelio būsenos rodymo būdas", + "open-normal": "Normalus vaizdas", + "open-in-separate-dialog": "Atidaryti atskirame dialoge", + "open-in-popover": "Atidaryti iškylančiame lange", + "dialog-title": "Dialogo pavadinimas", + "dialog-hide-dashboard-toolbar": "Slėpti skydelio įrankių juostą dialoge", + "dialog-width": "Dialogo plotis (procentais nuo lango pločio)", + "dialog-height": "Dialogo aukštis (procentais nuo lango aukščio)", + "dialog-size-range-error": "Dialogo dydžio reikšmė turi būti nuo 1 iki 100 procentų.", + "popover-preferred-placement": "Pageidaujama iškylančio lango padėtis", + "popover-placement-top": "Viršuje", + "popover-placement-topLeft": "Viršuje kairėje", + "popover-placement-topRight": "Viršuje dešinėje", + "popover-placement-right": "Dešinėje", + "popover-placement-rightTop": "Dešinėje viršuje", + "popover-placement-rightBottom": "Dešinėje apačioje", + "popover-placement-bottom": "Apačioje", + "popover-placement-bottomLeft": "Apačioje kairėje", + "popover-placement-bottomRight": "Apačioje dešinėje", + "popover-placement-left": "Kairėje", + "popover-placement-leftTop": "Kairėje viršuje", + "popover-placement-leftBottom": "Kairėje apačioje", + "popover-hide-on-click-outside": "Slėpti iškylantį langą spustelėjus už ribų", + "popover-hide-dashboard-toolbar": "Slėpti skydelio įrankių juostą iškylančiame lange", + "popover-width": "Iškylančio lango plotis naršyklės vienetais (pvz. 100px, 25vw)", + "popover-height": "Iškylančio lango aukštis naršyklės vienetais (pvz. 100px, 25vh)", + "popover-style": "Iškylančio lango stilius", + "open-new-browser-tab": "Atidaryti naujame naršyklės skirtuke", + "open-URL": "Atidaryti URL", + "URL": "URL", + "url-required": "URL būtinas.", + "mobile": { + "device-provision": "Įrenginio paruošimas", + "action-type": "Mobiliojo veiksmo tipas", + "select-action-type": "Pasirinkite mobiliojo veiksmo tipą", + "action-type-required": "Mobiliojo veiksmo tipas būtinas.", + "take-picture-from-gallery": "Pasirinkti nuotrauką iš galerijos", + "take-photo": "Padaryti nuotrauką", + "map-direction": "Atidaryti maršrutą žemėlapyje", + "map-location": "Atidaryti vietą žemėlapyje", + "scan-qr-code": "Skenuoti QR kodą", + "make-phone-call": "Skambinti telefonu", + "get-location": "Gauti telefono vietą", + "take-screenshot": "Padaryti ekrano nuotrauką" + }, + "custom-action-function": "Pasirinktinio veiksmo funkcija", + "custom-pretty-function": "Pasirinktinio veiksmo (su HTML šablonu) funkcija", + "map-item-type": "Žemėlapio elemento tipas", + "map-item": { + "marker": "Žymeklis", + "polygon": "Daugiakampis", + "rectangle": "Stačiakampis", + "circle": "Apskritimas" + }, + "place-map-item": "Įdėti žemėlapio elementą", + "map-item-tooltip": { + "customize-map-item-tooltips": "Tinkinti žemėlapio elementų paaiškinimus (tooltips)", + "place-marker": "Padėti žymeklį", + "start-draw-rectangle": "Pradėti piešti stačiakampį", + "finish-draw-rectangle": "Baigti piešti stačiakampį", + "start-draw-polygon": "Pradėti piešti daugiakampį", + "continue-draw-polygon": "Tęsti daugiakampio piešimą", + "finish-draw-polygon": "Baigti piešti daugiakampį", + "start-draw-circle": "Pradėti piešti apskritimą", + "finish-draw-circle": "Baigti piešti apskritimą" + } + }, + "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", + "scada": "SCADA valdiklių rinkinys", + "order": "Eiliškumas", + "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 pašalinta.", + "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ų rinkinius} }", + "delete-widgets-bundles-text": "Būkite dėmesingi — po patvirtinimo visi pasirinkti valdiklių rinkiniai ir su jais susijusi informacija bus pašalinti.", + "no-widgets-bundles-matching": "Valdiklių rinkinių, atitinkančių '{{widgetsBundle}}', nėra.", + "widgets-bundle-required": "Valdiklių rinkinys būtinas.", + "system": "Sisteminis", + "import": "Importuoti valdiklių rinkinį", + "export": "Eksportuoti valdiklių rinkinį", + "export-widgets-bundle-widgets-prompt": "Įtraukti valdiklių tipus į eksportuotus duomenis (kitu atveju bus eksportuojami tik nurodytų valdiklių FQN identifikatoriai)", + "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...", + "create-new": "Sukurti naują valdiklių rinkinį" + }, + "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ą", + "background-color": "Fono spalva", + "text-color": "Teksto spalva", + "border-radius": "Rėmelio apvalinimas", + "padding": "Vidinis atstumas (padding)", + "margin": "Išorinis atstumas (margin)", + "widget-style": "Valdiklio stilius", + "widget-css": "Valdiklio CSS stilius", + "title-style": "Antraštės stilius", + "mobile-mode-settings": "Mobiliojo režimo nustatymai", + "order": "Rikiavimo tvarka", + "height": "Aukštis", + "mobile-hide": "Slėpti valdiklį mobiliajame režime", + "desktop-hide": "Slėpti valdiklį darbalaukio režime", + "units": "Papildomas simbolis šalia reikšmės", + "units-by-default": "Numatytieji matavimo vienetai", + "decimals": "Skaitmenys po kablelio", + "decimals-by-default": "Numatytieji dešimtainiai skaitmenys", + "default-data-key-parameter-hint": "Šis parametras taikomas visoms valdiklio reikšmėms, nebent jis perrašomas konkretaus duomenų rakto konfigūracijoje", + "units-short": "Vienetai", + "decimals-short": "Dešimtainiai", + "decimals-suffix": "dešimtainiai skaitmenys", + "digits-suffix": "skaitmenys", + "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 duomenų šaltinis.} other {# duomenų šaltiniai} }", + "timeseries-key-error": "Reikia nurodyti bent 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", + "select-action-source": "Pasirinkite veiksmo šaltinį", + "action-source-required": "Veiksmo šaltinis būtinas.", + "column-index": "Stulpelio indeksas", + "select-column-index": "Pasirinkite stulpelio indeksą", + "column-index-required": "Stulpelio indeksas būtinas.", + "not-set": "Nenurodyta", + "action-name": "Veiksmo pavadinimas", + "action-name-required": "Veiksmo pavadinimas būtinas.", + "action-name-not-unique": "Veiksmas su tokiu pavadinimu jau egzistuoja.
    To paties šaltinio veiksmų pavadinimai negali kartotis.", + "action-icon": "Piktograma", + "header-button": { + "button-settings": "Mygtuko nustatymai", + "button-type": "Mygtuko tipas", + "button-type-basic": "Paprastas", + "button-type-raised": "Iškeltas (Raised)", + "button-type-stroked": "Kontūrinis (Stroked)", + "button-type-flat": "Plokščias (Flat)", + "button-type-icon": "Piktogramos mygtukas", + "button-type-mini-fab": "Mini FAB (Floating Action Button)", + "colors": "Spalvos", + "color": "Spalva", + "background": "Fonas", + "border": "Rėmelis", + "advanced-button-style": "Išplėstinis mygtuko stilius", + "button-style": "Mygtuko stilius" + }, + "show-hide-action-using-function": "Rodyti / slėpti veiksmą pagal funkciją", + "show-action-function": "Veiksmo rodymo funkcija", + "action-type": "Veiksmo 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 piktogramą", + "card-icon": "Kortelės piktograma", + "icon": "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": "Alternatyvus tekstas, kai nėra duomenų", + "data-page-size": "Maksimalus subjektų skaičius viename duomenų šaltinyje", + "settings-component-not-found": "Nerasta nustatymų formos komponento su selektoriumi '{{selector}}'", + "preview": "Peržiūra", + "set": "Nustatyti", + "set-message": "Nustatyti pranešimą", + "advanced-title-style": "Išplėstinis antraštės stilius", + "card-style": "Kortelės stilius", + "text": "Tekstas", + "background": "Fonas", + "advanced-widget-style": "Išplėstinis valdiklio stilius", + "card-buttons": "Kortelės mygtukai", + "show-card-buttons": "Rodyti kortelės mygtukus", + "card-border-radius": "Kortelės kampų apvalinimas", + "card-padding": "Kortelės vidinis atstumas (padding)", + "card-appearance": "Kortelės išvaizda", + "color": "Spalva", + "tooltip": "Paaiškinimas (tooltip)", + "units-required": "Matavimo vienetas būtinas.", + "list-layout": "Sąrašo išdėstymas", + "layout": "Išdėstymas", + "resize-options": "Dydžio keitimo parinktys", + "resizable": "Keičiamo dydžio", + "preserve-aspect-ratio": "Išlaikyti proporcijas" + }, + "widget-type": { + "import": "Importuoti valdiklį", + "export": "Eksportuoti valdiklį", + "export-failed-error": "Valdiklio eksportuoti nepavyko: {{error}}", + "widget-file": "Valdiklio failas", + "invalid-widget-file-error": "Nepavyko importuoti valdiklio: neteisinga valdiklio duomenų struktūra." + }, + "markdown": { + "edit": "Redaguoti", + "preview": "Peržiūra", + "copy-code": "Spustelėkite, kad nukopijuotumėte", + "copied": "Nukopijuota!" + }, + "widgets": { + "mobile-app-qr-code": { + "configuration-hint": "Konfigūracija priklauso nuo mobiliosios aplikacijos QR kodo valdiklio pagrindiniuose platformos nustatymuose", + "get-it-on-google-play": "Atsisiųsti iš Google Play", + "download-on-the-app-store": "Atsisiųsti iš App Store" + }, + "action-button": { + "behavior": "Elgsena", + "on-click": "Paspaudus", + "on-click-hint": "Veiksmas, kuris įvykdomas paspaudus mygtuką", + "first-button-click": "Pirmas mygtuko paspaudimas", + "first-button-click-hint": "Veiksmas, vykdomas pirmo mygtuko paspaudimo metu.", + "second-button-click": "Antras mygtuko paspaudimas", + "second-button-click-hint": "Veiksmas, vykdomas antro mygtuko paspaudimo metu.", + "button-click-hint": "Veiksmas, vykdomas paspaudus valdiklį." + }, + "command-button": { + "behavior": "Elgsena", + "on-click": "Paspaudus", + "on-click-hint": "Veiksmas, atliekamas paspaudus mygtuką." + }, + "power-button": { + "behavior": "Elgsena", + "power-on": "Įjungti", + "power-on-hint": "Veiksmas, atliekamas įjungiant komponentą.", + "power-off": "Išjungti", + "power-off-hint": "Veiksmas, atliekamas išjungiant komponentą.", + "on-label": "Įjungta", + "off-label": "Išjungta", + "layout": "Išdėstymas", + "layout-default": "Numatytasis", + "layout-simplified": "Supaprastintas", + "layout-outlined": "Aptvertas", + "layout-default-volume": "Numatytasis.Garsas", + "layout-simplified-volume": "Supaprastintas.Garsas", + "layout-outlined-volume": "Aptvertas.Garsas", + "layout-default-icon": "Numatytasis.Piktograma", + "layout-simplified-icon": "Supaprastintas.Piktograma", + "layout-outlined-icon": "Aptvertas.Piktograma", + "main": "Pagrindinis", + "background": "Fonas", + "button-icon-on": "Mygtuko piktograma 'Įjungta'", + "button-icon-off": "Mygtuko piktograma 'Išjungta'", + "power-on-colors": "Įjungimo spalvos", + "power-off-colors": "Išjungimo spalvos", + "disabled-colors": "Išjungtos spalvos", + "button": "Mygtukas" + }, + "toggle-button": { + "behavior": "Elgsena", + "checked": "Pažymėta", + "unchecked": "Nepažymėta", + "check": "Pažymėti", + "check-hint": "Veiksmas, atliekamas pažymint komponentą.", + "uncheck": "Nuimti žymėjimą", + "uncheck-hint": "Veiksmas, atliekamas nuimant žymėjimą nuo komponento.", + "auto-scale": "Automatinis mastelis", + "horizontal-fill": "Horizontalus užpildymas", + "vertical-fill": "Vertikalus užpildymas", + "button-appearance": "Mygtuko išvaizda" + }, + "segmented-button": { + "layout": "Išdėstymas", + "layout-squared": "Kampuotas", + "layout-rounded": "Apvalintas", + "card-border": "Kortelės rėmelis", + "button-appearance": "Mygtuko išvaizda", + "first": "Pirmas", + "second": "Antras", + "color-styles": "Spalvų stiliai", + "selected": "Pasirinkta", + "unselected": "Nepasirinkta" + }, + "button": { + "layout": "Išdėstymas", + "outlined": "Aptvertas", + "filled": "Užpildytas", + "underlined": "Pabrauktas", + "basic": "Paprastas", + "auto-scale": "Automatinis mastelis", + "label": "Etiketė", + "icon": "Piktograma", + "border-radius": "Kampų apvalinimas", + "color-palette": "Spalvų paletė", + "main": "Pagrindinis", + "background": "Fonas", + "border": "Rėmelis", + "custom-styles": "Tinkinti stiliai", + "clear-style": "Išvalyti stilių", + "shadow": "Šešėlis", + "enabled": "Įjungta", + "disabled": "Išjungta", + "preview": "Peržiūra", + "copy-style-from": "Kopijuoti stilių iš" + }, + "value-stepper": { + "behavior": "Elgsena", + "simplified": "Supaprastintas", + "filled": "Užpildytas", + "outlined": "Aptvertas", + "volume": "Garsas", + "initial-state": "Pradinė būsena", + "initial-state-hint": "Veiksmas, skirtas gauti pradinę reikšmę.", + "disabled-state": "Išjungta būsena", + "disabled-state-hint": "Nustatykite sąlygą, kada komponentas yra išjungiamas.", + "right-button-click": "Dešinio mygtuko paspaudimas", + "right-button-click-hint": "Veiksmas paspaudus dešinį mygtuką.", + "left-button-click": "Kairio mygtuko paspaudimas", + "left-button-click-hint": "Veiksmas paspaudus kairį mygtuką.", + "auto-scale": "Automatinis mastelis", + "value-range": "Reikšmių intervalas", + "min-range": "Minimali reikšmė", + "max-range": "Maksimali reikšmė", + "value-increment-decrement-step": "Reikšmės didinimo/mažinimo žingsnis", + "value": "Reikšmė", + "value-box-background": "Reikšmės laukelio fonas", + "border": "Rėmelis", + "button-appearance": "Mygtuko išvaizda", + "left": "Kairė", + "right": "Dešinė", + "left-button": "Kairysis mygtukas", + "right-button": "Dešinysis mygtukas", + "icon": "Piktograma", + "color-palette": "Spalvų paletė", + "main": "Pagrindinis", + "background": "Fonas", + "button-icon-on": "Mygtuko piktograma 'Įjungta'", + "button-on-colors": "Įjungimo spalvos", + "disabled-colors": "Išjungtos spalvos" + }, + "button-state": { + "activated-state": "Aktyvuota būsena", + "activated-state-hint": "Nustatykite sąlygą, kada mygtukas yra aktyvus.", + "disabled-state": "Išjungta būsena", + "disabled-state-hint": "Nustatykite sąlygą, kada mygtukas yra išjungiamas.", + "selected-state": "Pasirinkta būsena", + "selected-state-hint": "Nustatykite sąlygą, kada mygtukas yra pasirinktas.", + "enabled": "Įjungta", + "hovered": "Užvestas", + "pressed": "Paspaustas", + "activated": "Aktyvuotas", + "disabled": "Išjungtas", + "initial": "Pirmasis mygtukas", + "first": "Pirmas", + "second": "Antras" + }, + "background": { + "background": "Fonas", + "background-settings": "Fono nustatymai", + "background-type-image": "Įkelti paveikslėlį", + "background-type-color": "Vientisa spalva", + "image-url": "Paveikslėlio URL", + "overlay": "Perdanga", + "enable-overlay": "Įjungti perdangą", + "blur": "Suliejimas", + "preview": "Peržiūra" + }, + "bar-chart": { + "bar-appearance": "Stulpelio išvaizda", + "label-on-bar": "Etiketė ant stulpelio", + "value-on-bar": "Reikšmė ant stulpelio", + "bar-chart-style": "Stulpelinės diagramos stilius", + "bar-axis": "Stulpelio ašis" + }, + "polar-area-chart": { + "polar-axis": "Polinė ašis", + "start-angle": "Pradinis kampas", + "polar-area-chart-style": "Polinės diagramos stilius" + }, + "battery-level": { + "layout": "Išdėstymas", + "layout-vertical-solid": "Vertikalus. Vientisas", + "layout-horizontal-solid": "Horizontalus. Vientisas", + "layout-vertical-divided": "Vertikalus. Padalintas", + "layout-horizontal-divided": "Horizontalus. Padalintas", + "icon": "Piktograma", + "value": "Reikšmė", + "auto-scale": "Automatinis mastelis", + "battery-level-color": "Baterijos lygio spalva", + "battery-shape-color": "Baterijos formos spalva", + "battery-level-card-style": "Baterijos lygio kortelės stilius", + "sections-count": "Sekcijų skaičius" + }, + "signal-strength": { + "value": "Reikšmė", + "last-update": "Paskutinis atnaujinimas", + "no-signal": "Nėra signalo", + "layout": "Išdėstymas", + "layout-wifi": "Wi-Fi", + "layout-cellular-bar": "Mobiliojo ryšio stulpeliai", + "icon": "Piktograma", + "date": "Data", + "active-bars-color": "Aktyvių signalo stulpelių spalva", + "inactive-bars-color": "Neaktyvių signalo stulpelių spalva", + "signal-strength-card-style": "Signalo stiprumo kortelės stilius", + "no-signal-rssi-value": "\"Nėra signalo\" RSSI reikšmė" + }, + "status-widget": { + "behavior": "Elgsena", + "layout": "Išdėstymas", + "layout-default": "Numatytasis", + "layout-center": "Centruotas", + "layout-icon": "Piktograma", + "on": "Įjungta", + "off": "Išjungta", + "label": "Etiketė", + "status": "Būsena", + "icon": "Piktograma", + "color-palette": "Spalvų paletė", + "disabled-color-palette": "Išjungta spalvų paletė", + "primary": "Pagrindinė", + "primary-color-hint": "Piktogramos ir etiketės spalva", + "secondary": "Antrinė", + "secondary-color-hint": "Būsenos spalva", + "background": "Fonas" + }, + "chart": { + "common-settings": "Bendrieji nustatymai", + "enable-stacking-mode": "Įjungti kaupimo režimą", + "selection": "Laiko intervalo pasirinkimas", + "enable-selection-mode": "Įjungti pasirinkimo režimą", + "line-shadow-size": "Linijos šešėlio dydis", + "display-smooth-lines": "Rodyti glotnias (lenktas) linijas", + "default-bar-width": "Numatytasis stulpelio plotis neagreguotiems duomenims (milisekundėmis)", + "bar-alignment": "Stulpelių lygiavimas", + "bar-alignment-left": "Kairėje", + "bar-alignment-right": "Dešinėje", + "bar-alignment-center": "Centre", + "default-font": "Numatytasis šriftas", + "default-font-size": "Numatytasis šrifto dydis", + "default-font-color": "Numatytoji šrifto spalva", + "thresholds-line-width": "Numatytasis ribų linijos plotis", + "tooltip-settings": "Užuominos nustatymai", + "tooltip": "Užuomina", + "show-tooltip": "Rodyti užuominą", + "hover-individual-points": "Užvesti virš atskirų taškų", + "show-cumulative-values": "Rodyti sumines reikšmes kaupimo režime", + "hide-zero-false-values": "Slėpti nulinės/neteisingos reikšmės užuominose", + "tooltip-value-format-function": "Užuominos reikšmės formatavimo funkcija", + "grid-settings": "Tinklelio nustatymai", + "show-vertical-lines": "Rodyti vertikalias linijas", + "show-horizontal-lines": "Rodyti horizontalias linijas", + "grid-outline-border-width": "Tinklelio kontūro/rėmelio plotis (px)", + "primary-color": "Pagrindinė spalva", + "background-color": "Fono spalva", + "ticks-color": "Atskirų padalų spalva", + "xaxis-settings": "X ašies nustatymai", + "axis-title": "Ašies pavadinimas", + "xaxis-tick-labels-settings": "X ašies padalų etikečių nustatymai", + "show-tick-labels": "Rodyti ašies padalų etiketes", + "yaxis-settings": "Y ašies nustatymai", + "min-scale-value": "Minimalioji skalės reikšmė", + "max-scale-value": "Maksimalioji skalės reikšmė", + "yaxis-tick-labels-settings": "Y ašies padalų etikečių nustatymai", + "tick-step-size": "Padalų žingsnio dydis", + "number-of-decimals": "Rodyti dešimtainių skaičių kiekis", + "ticks-formatter-function": "Padalų formatavimo funkcija", + "comparison-settings": "Palyginimo nustatymai", + "enable-comparison": "Įjungti palyginimą", + "time-for-comparison": "Palyginimo laikotarpis", + "time-for-comparison-previous-interval": "Ankstesnis intervalas (numatytasis)", + "time-for-comparison-days": "Prieš dieną", + "time-for-comparison-weeks": "Prieš savaitę", + "time-for-comparison-months": "Prieš mėnesį", + "time-for-comparison-years": "Prieš metus", + "time-for-comparison-custom-interval": "Pasirinktinis intervalas", + "custom-interval-value": "Pasirinktinis intervalo dydis (ms)", + "comparison-x-axis-settings": "Palyginimo X ašies nustatymai", + "axis-position": "Ašies padėtis", + "axis-position-top": "Viršuje (numatyta)", + "axis-position-bottom": "Apačioje", + "custom-legend-settings": "Tinkintos legendos nustatymai", + "enable-custom-legend": "Įjungti tinkintą legendą (leidžia naudoti atributų/laikinių eilučių reikšmes etiketėse)", + "key-name": "Raktinio parametro pavadinimas", + "key-name-required": "Būtina nurodyti raktinio parametro pavadinimą", + "key-type": "Rakto tipas", + "key-type-attribute": "Atributas", + "key-type-timeseries": "Laikinė eilutė", + "label-keys-list": "Etiketėse naudojamų raktų sąrašas", + "no-label-keys": "Nesukonfigūruoti jokie raktai", + "add-label-key": "Pridėti naują raktą", + "line-width": "Linijos plotis", + "color": "Spalva", + "data-is-hidden-by-default": "Duomenys pagal numatymą paslėpti", + "disable-data-hiding": "Išjungti duomenų slėpimą", + "remove-from-legend": "Pašalinti duomenų raktą iš legendos", + "exclude-from-stacking": "Pašalinti iš kaupimo (prieinama tik „Kaupimo“ režime)", + "line-settings": "Linijos nustatymai", + "show-line": "Rodyti liniją", + "fill-line": "Užpildyti liniją", + "fill-line-opacity": "Užpildymo nepermatomumas", + "points-settings": "Taškų nustatymai", + "show-points": "Rodyti taškus", + "points-line-width": "Taškų kontūro plotis", + "points-radius": "Taškų spindulys", + "point-shape": "Taško forma", + "point-shape-circle": "Apskritimas", + "point-shape-cross": "Kryžius", + "point-shape-diamond": "Deimantas", + "point-shape-square": "Kvadratas", + "point-shape-triangle": "Trikampis", + "point-shape-custom": "Tinkinta funkcija", + "point-shape-draw-function": "Taško piešimo funkcija", + "show-separate-axis": "Rodyti atskirą ašį", + "axis-position-left": "Kairėje", + "axis-position-right": "Dešinėje", + "thresholds": "Ribos", + "no-thresholds": "Ribos nesukonfigūruotos", + "add-threshold": "Pridėti ribą", + "show-values-for-comparison": "Rodyti istorines reikšmes palyginimui", + "comparison-values-label": "Istorinių reikšmių etiketė", + "comparison-line-color": "Palyginimo linijos spalva", + "threshold-settings": "Ribų nustatymai", + "use-as-threshold": "Naudoti rakto reikšmę kaip ribą", + "threshold-line-width": "Ribos linijos plotis", + "threshold-color": "Ribos spalva", + "common-pie-settings": "Bendrieji skritulinės diagramos nustatymai", + "radius": "Spindulys", + "inner-radius": "Vidinis spindulys", + "tilt": "Pasvirimas", + "common-pie-settings-range-error": "Reikšmė turi būti nuo 0 iki 1", + "stroke-settings": "Kontūro nustatymai", + "width-pixels": "Plotis (pikseliais)", + "show-labels": "Rodyti etiketes", + "animation-settings": "Animacijos nustatymai", + "animated-pie": "Įjungti skritulinės diagramos animaciją (eksperimentinė funkcija)", + "border-settings": "Rėmelio nustatymai", + "border-width": "Rėmelio plotis", + "border-color": "Rėmelio spalva", + "legend-settings": "Legendos nustatymai", + "display-legend": "Rodyti legendą", + "labels-font-color": "Etikečių šrifto spalva", + "series": "Serijos", + "add-series": "Pridėti seriją", + "series-settings": "Serijos nustatymai", + "remove-series": "Pašalinti seriją", + "no-series": "Nesukonfigūruota jokia serija", + "no-series-error": "Būtina nurodyti bent vieną seriją", + "chart-appearance": "Diagramos išvaizda", + "vertical-grid-lines": "Vertikalios tinklelio linijos", + "horizontal-grid-lines": "Horizontalios tinklelio linijos", + "chart-background": "Diagramos fonas", + "grid-lines-color": "Tinklelio linijų spalva", + "border": "Rėmelis", + "axis": "Ašis", + "vertical-axis": "Vertikali ašis", + "ticks": "Padalos", + "horizontal-axis": "Horizontali ašis", + "shape-empty-circle": "Tuščias apskritimas", + "shape-circle": "Apskritimas", + "shape-rect": "Stačiakampis", + "shape-round-rect": "Apvalintų kampų stačiakampis", + "shape-triangle": "Trikampis", + "shape-diamond": "Deimantas", + "shape-pin": "Žymeklis", + "shape-arrow": "Rodyklė", + "shape-none": "Nėra", + "line-type-solid": "Vientisa", + "line-type-dashed": "Brūkšniuota", + "line-type-dotted": "Taškuota", + "label-position-top": "Viršuje", + "label-position-bottom": "Apačioje", + "label-position-outside": "Išorėje", + "label-position-inside": "Viduje", + "fill": "Užpildas", + "fill-type-none": "Nėra", + "fill-type-solid": "Vientisas", + "fill-type-opacity": "Permatomumas", + "fill-type-gradient": "Gradientas", + "background": "Fonas", + "opacity": "Permatomumas", + "gradient-stops": "Gradiento taškai", + "gradient-start": "pradžia", + "gradient-end": "pabaiga", + "animation": { + "animation": "Animacija", + "animation-threshold": "Animacijos riba", + "animation-duration": "Animacijos trukmė", + "animation-easing": "Animacijos švelninimas", + "animation-delay": "Animacijos delsimas", + "update-animation-duration": "Atnaujinimo animacijos trukmė", + "update-animation-easing": "Atnaujinimo animacijos švelninimas", + "update-animation-delay": "Atnaujinimo animacijos delsimas" + }, + "chart-axis": { + "scale": "Skalė", + "scale-min": "min", + "scale-max": "maks", + "scale-auto": "Automatinė" + }, + "bar": { + "show-border": "Rodyti rėmelį", + "border-width": "Rėmelio plotis", + "border-radius": "Kampų apvalinimas", + "bar-width": "Stulpelio plotis", "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." - } + "label-hint": "Rodyti etiketę virš stulpelio.", + "series-label-hint": "Rodyti etiketę su reikšme virš stulpelio.", + "label-background": "Etiketės fonas" + } }, - "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-additional-info": "Papildoma informacija", - "edit-alarm-rule-additional-info": "Redaguoti papildomą informaciją", - "alarm-rule-additional-info-placeholder": "Pateikite savo komentarus ir patikslinimus čia, kad jie būtų rodomi pavojaus signalo detalių skiltyje „Papildoma informacija“.", - "alarm-rule-additional-info-hint": "Hint: use ${keyName} to substitute values of the attribute or telemetry keys that are used in alarm rule condition.", - "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-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 {{ min }} to {{ max }}.", - "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." - } + "color": { + "color-settings": "Spalvų nustatymai", + "color-type-constant": "Pastovi", + "color-type-gradient": "Gradientas", + "color-type-range": "Intervalas", + "color-type-function": "Funkcija", + "color": "Spalva", + "value-range": "Reikšmių intervalas", + "from": "Nuo", + "to": "Iki", + "color-function": "Spalvos funkcija", + "copy-color-settings-from": "Kopijuoti spalvų nustatymus iš", + "copy-from": "Kopijuoti iš", + "settings-type": "Nustatymų tipas", + "basic-mode": "Paprastas", + "advanced-mode": "Išplėstinis", + "entity-alias": "Objekto pseudonimas", + "entity-attribute": "Objekto atributas", + "gradient-color": "Gradiento spalva", + "gradient-color-min": "Spalva", + "gradient-start": "Gradiento pradžios spalva", + "gradient-start-min": "Pradžia", + "gradient-end": "Gradiento pabaigos spalva", + "gradient-end-min": "Pabaiga", + "start-value": "Pradinė reikšmė", + "end-value": "Galutinė reikšmė", + "gradient-type": "Gradiento tipas" + }, + "dashboard-state": { + "dashboard-state-settings": "Valdymo skydelio būsenos nustatymai", + "dashboard-state": "Valdymo skydelio būsenos ID", + "autofill-state-layout": "Automatiškai užpildyti būsenos išdėstymo aukštį pagal numatymą", + "default-margin": "Numatytasis valdiklių tarpas", + "default-background-color": "Numatytoji fono spalva", + "sync-parent-state-params": "Sinchronizuoti būsenos parametrus su pagrindiniu valdymo skydeliu" + }, + "date-range-navigator": { + "date-range-picker-settings": "Datų intervalo parinkiklio nustatymai", + "hide-date-range-picker": "Slėpti datų intervalo parinkiklį", + "picker-one-panel": "Datų intervalo parinkiklis viename skydelyje", + "picker-auto-confirm": "Automatinis datų intervalo patvirtinimas", + "picker-show-template": "Rodyti datų intervalo šabloną", + "first-day-of-week": "Pirmoji savaitės diena", + "interval-settings": "Intervalo nustatymai", + "hide-interval": "Slėpti intervalą", + "initial-interval": "Pradinis intervalas", + "interval-hour": "Valanda", + "interval-day": "Diena", + "interval-week": "Savaitė", + "interval-two-weeks": "2 savaitės", + "interval-month": "Mėnuo", + "interval-three-months": "3 mėnesiai", + "interval-six-months": "6 mėnesiai", + "step-settings": "Žingsnio nustatymai", + "hide-step-size": "Slėpti žingsnio dydį", + "initial-step-size": "Pradinis žingsnio dydis", + "hide-labels": "Slėpti etiketes", + "use-session-storage": "Naudoti sesijos saugyklą", + "localizationMap": { + "Sun": "Sek", + "Mon": "Pir", + "Tue": "Ant", + "Wed": "Tre", + "Thu": "Ket", + "Fri": "Pen", + "Sat": "Šeš", + "Jan": "Sau", + "Feb": "Vas", + "Mar": "Kov", + "Apr": "Bal", + "May": "Geg", + "Jun": "Bir", + "Jul": "Lie", + "Aug": "Rgp", + "Sep": "Rgs", + "Oct": "Spa", + "Nov": "Lap", + "Dec": "Grd", + "January": "Sausis", + "February": "Vasaris", + "March": "Kovas", + "April": "Balandis", + "June": "Birželis", + "July": "Liepa", + "August": "Rugpjūtis", + "September": "Rugsėjis", + "October": "Spalis", + "November": "Lapkritis", + "December": "Gruodis", + "Custom Date Range": "Pasirinktinis datų intervalas", + "Date Range Template": "Datų intervalo šablonas", + "Today": "Šiandien", + "Yesterday": "Vakar", + "This Week": "Šią savaitę", + "Last Week": "Praėjusią savaitę", + "This Month": "Šį mėnesį", + "Last Month": "Praėjusį mėnesį", + "Year": "Metai", + "This Year": "Šiais metais", + "Last Year": "Praėjusiais metais", + "Date picker": "Datų parinkiklis", + "Hour": "Valanda", + "Day": "Diena", + "Week": "Savaitė", + "2 weeks": "2 savaitės", + "Month": "Mėnuo", + "3 months": "3 mėnesiai", + "6 months": "6 mėnesiai", + "Custom interval": "Pasirinktinis intervalas", + "Interval": "Intervalas", + "Step size": "Žingsnio dydis", + "Ok": "Gerai" + } }, - "dialog": { - "close": "Uždaryti dialogo langą", - "error-message-title": "Klaidos pranešimas:", - "error-details-title": "Informacija apie klaidą" + "doughnut": { + "doughnut-appearance": "Žiedo diagramos išvaizda", + "layout": "Išdėstymas", + "layout-default": "Numatytasis", + "layout-with-total": "Su suma", + "central-total-value": "Centrinė bendra reikšmė", + "doughnut-card-style": "Žiedo diagramos kortelės stilius" }, - "direction": { - "column": "Stulpelis", - "row": "Eilutė" + "entities-hierarchy": { + "hierarchy-data-settings": "Hierarchijos duomenų nustatymai", + "relations-query-function": "Mazgo ryšių užklausos funkcija", + "has-children-function": "Funkcija, tikrinanti, ar mazgas turi vaikų", + "node-state-settings": "Mazgo būsenos nustatymai", + "node-opened-function": "Numatytoji mazgo atidarymo funkcija", + "node-disabled-function": "Mazgo išjungimo funkcija", + "display-settings": "Atvaizdavimo nustatymai", + "node-icon-function": "Mazgo piktogramos funkcija", + "node-text-function": "Mazgo teksto funkcija", + "sort-settings": "Rikiavimo nustatymai", + "nodes-sort-function": "Mazgų rikiavimo funkcija" }, "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" + "display-default-title": "Rodyti numatytąjį pavadinimą" }, - "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" + "gateway": { + "general-settings": "Bendrieji nustatymai", + "widget-title": "Valdiklio pavadinimas", + "default-archive-file-name": "Numatytasis archyvo failo pavadinimas", + "device-type-for-new-gateway": "Įrenginio tipas naujam tinklo šliuzui", + "messages-settings": "Pranešimų nustatymai", + "save-config-success-message": "Tekstas, rodomas sėkmingai išsaugojus šliuzo konfigūraciją", + "device-name-exists-message": "Tekstas, rodomas kai įrenginys su įvestu pavadinimu jau egzistuoja", + "gateway-title": "Šliuzo forma", + "read-only": "Tik skaitymui", + "events-title": "Šliuzo įvykių formos pavadinimas", + "events-filter": "Įvykių filtras", + "event-key-contains": "Įvykio rakte yra...", + "show-connector": "Rodyti jungtį", + "connector-state-param-key": "Jungties būsenos parametro raktas", + "message": "Pranešimas", + "level": "Lygis", + "created-time": "Sukūrimo laikas" + }, + "gauge": { + "default-color": "Numatytoji spalva", + "radial-gauge-settings": "Radialinio matuoklio nustatymai", + "ticks-settings": "Padalų nustatymai", + "min-value": "Minimali reikšmė", + "max-value": "Maksimali reikšmė", + "min-value-short": "min", + "max-value-short": "maks", + "start-ticks-angle": "Pradinis padalų kampas", + "ticks-angle": "Padalų kampas", + "major-ticks": "Pagrindinės padalos", + "major-ticks-count": "Pagrindinių padalų skaičius", + "major-ticks-color": "Pagrindinių padalų spalva", + "minor-ticks": "Antrinės padalos", + "minor-ticks-count": "Antrinių padalų skaičius", + "minor-ticks-color": "Antrinių padalų spalva", + "tick-numbers-font": "Padalų numerių šriftas", + "unit-title-settings": "Matavimo vieneto pavadinimo nustatymai", + "show-unit-title": "Rodyti vieneto pavadinimą", + "unit-title": "Vieneto pavadinimas", + "title-font": "Pavadinimo teksto šriftas", + "units-settings": "Vienetų nustatymai", + "units-font": "Vienetų teksto šriftas", + "value-box-settings": "Reikšmės laukelio nustatymai", + "show-value-box": "Rodyti reikšmės laukelį", + "value-box": "Reikšmės laukelis", + "value-int": "Skaitmenų skaičius sveikai reikšmės daliai", + "value-text": "Reikšmės tekstas", + "value-text-shadow": "Reikšmės teksto šešėlis", + "value-font": "Reikšmės teksto šriftas", + "rect-stroke-color-start": "Stačiakampio rėmelio spalva – gradiento pradžia", + "rect-stroke-color-end": "Stačiakampio rėmelio spalva – gradiento pabaiga", + "background-color": "Fono spalva", + "shadow-color": "Šešėlio spalva", + "value-box-rect-stroke-color": "Reikšmės laukelio rėmelio spalva", + "value-box-rect-stroke-color-end": "Reikšmės laukelio rėmelio spalva – gradiento pabaiga", + "value-box-background-color": "Reikšmės laukelio fono spalva", + "value-box-shadow-color": "Reikšmės laukelio šešėlio spalva", + "plate-settings": "Plokštės nustatymai", + "show-plate-border": "Rodyti plokštės rėmelį", + "plate-color": "Plokštės spalva", + "needle-settings": "Rodyklės nustatymai", + "needle-circle-size": "Rodyklės apskritimo dydis", + "needle-color": "Rodyklės spalva", + "needle-color-start": "Rodyklės spalva – gradiento pradžia", + "needle-color-end": "Rodyklės spalva – gradiento pabaiga", + "needle-color-shadow-up": "Viršutinės rodyklės dalies šešėlio spalva", + "needle-color-shadow-down": "Rodyklės metamo šešėlio spalva", + "highlights-settings": "Paryškinimų nustatymai", + "highlights-width": "Paryškinimų plotis", + "highlights": "Paryškinimai", + "highlight-from": "Nuo", + "highlight-to": "Iki", + "highlight-color": "Spalva", + "no-highlights": "Paryškinimai nesukonfigūruoti", + "add-highlight": "Pridėti paryškinimą", + "animation-settings": "Animacijos nustatymai", + "enable-animation": "Įjungti animaciją", + "animation-duration-rule": "Animacijos trukmės ir taisyklių nustatymas", + "animation-duration": "Animacijos trukmė", + "animation-rule": "Animacijos taisyklė", + "animation-linear": "Tiesinė", + "animation-quad": "Kvadratinė", + "animation-quint": "Penktos eilės", + "animation-cycle": "Ciklinė", + "animation-bounce": "Šokčiojanti", + "animation-elastic": "Elastinga", + "animation-dequad": "Atvirkštinė kvadratinė", + "animation-dequint": "Atvirkštinė penktos eilės", + "animation-decycle": "Atvirkštinis ciklas", + "animation-debounce": "Atšokimo mažinimas", + "animation-delastic": "Atvirkštinė elastinga", + "linear-gauge-settings": "Tiesinio matuoklio nustatymai", + "bar-stroke": "Juostos kontūras", + "bar-stroke-width": "Juostos kontūro plotis", + "bar-stroke-color": "Juostos kontūro spalva", + "bar-background-color": "Matuoklio juostos fono spalva", + "bar-background-color-end": "Juostos fono spalva – gradiento pabaiga", + "progress-bar-color": "Progreso juostos spalva", + "progress-bar": "Progreso juosta", + "progress-bar-color-start": "Progreso juostos spalva – gradiento pradžia", + "progress-bar-color-end": "Progreso juostos spalva – gradiento pabaiga", + "major-ticks-names": "Pagrindinių padalų pavadinimai", + "show-stroke-ticks": "Rodyti padalų kontūrus", + "major-ticks-font": "Pagrindinių padalų šriftas", + "border-color": "Rėmelio spalva", + "border-width": "Rėmelio plotis", + "needle-circle": "Rodyklės apskritimas", + "needle-circle-color": "Rodyklės apskritimo spalva", + "animation-target": "Animacijos objektas", + "animation-target-needle": "Rodyklė", + "animation-target-plate": "Plokštė", + "common-settings": "Bendrieji matuoklio nustatymai", + "gauge-type": "Matuoklio tipas", + "gauge-type-arc": "Lankas", + "gauge-type-donut": "Žiedas", + "gauge-type-horizontal-bar": "Horizontali juosta", + "gauge-type-vertical-bar": "Vertikali juosta", + "donut-start-angle": "Pradinis kampas", + "bar-settings": "Matuoklio juostos nustatymai", + "relative-bar-width": "Santykinis juostos plotis", + "neon-glow-brightness": "Neoninio švytėjimo ryškumas (0–100), 0 – išjungta", + "neon-glow-brightness-hint": "0 – išjungia efektą", + "stripes-thickness": "Juostelių storis, 0 – be juostelių", + "stripes-thickness-hint": "0 – be juostelių", + "rounded-line-cap": "Rodyti apvalintą linijos galą", + "bar-color-settings": "Juostos spalvų nustatymai", + "use-precise-level-color-values": "Naudoti tikslias spalvų reikšmes", + "bar-colors": "Juostos spalvos nuo žemiausios iki aukščiausios", + "color": "Spalva", + "no-bar-colors": "Juostos spalvos nesukonfigūruotos", + "add-bar-color": "Pridėti juostos spalvą", + "from": "Nuo", + "to": "Iki", + "fixed-level-colors": "Juostos spalvos pagal ribines reikšmes", + "gauge-title-settings": "Matuoklio pavadinimo nustatymai", + "show-gauge-title": "Rodyti matuoklio pavadinimą", + "gauge-title": "Matuoklio pavadinimas", + "gauge-title-font": "Matuoklio pavadinimo šriftas", + "unit-title-and-timestamp-settings": "Vieneto pavadinimo ir laiko žymos nustatymai", + "show-timestamp": "Rodyti reikšmės laiko žymą", + "timestamp-format": "Laiko žymos formatas", + "label-font": "Etiketės šriftas po reikšme", + "value-settings": "Reikšmės nustatymai", + "show-value": "Rodyti reikšmės tekstą", + "min-max-settings": "Minimalių/maksimalių reikšmių etikečių nustatymai", + "show-min-max": "Rodyti minimalias ir maksimalias reikšmes", + "min-max-font": "Minimalių ir maksimalių reikšmių šriftas", + "show-ticks": "Rodyti padalas", + "tick-width": "Padalos plotis", + "tick-color": "Padalos spalva", + "tick-values": "Padalų reikšmės", + "no-tick-values": "Padalų reikšmės nesukonfigūruotos", + "add-tick-value": "Pridėti padalos reikšmę", + "gauge-appearance": "Matuoklio išvaizda", + "units-title": "Vienetų pavadinimas", + "value": "Reikšmė", + "ticks": "Padalos", + "arrow-and-scale-color": "Rodyklės ir skalės numatytoji spalva", + "scale-settings": "Skalės nustatymai", + "scale": "Skalė", + "scale-color": "Skalės spalvos", + "compass-appearance": "Kompaso išvaizda", + "label": "Etiketė", + "labels": "Etiketės", + "label-style": "Etiketės stilius", + "simple-gauge-type": "Tipas", + "gauge-bar-background": "Matuoklio juostos fonas", + "bar-color": "Juostos spalva", + "min-and-max-value": "Minimalios ir maksimalios reikšmės", + "min-and-max-label": "Minimalios ir maksimalios etiketės", + "font": "Šriftas", + "tick-width-and-color": "Padalų plotis ir spalva", + "min-max-validation-text": "Maksimali reikšmė turi būti didesnė nei minimali" + }, + "gpio": { + "pin": "Kaištis", + "label": "Etiketė", + "row": "Eilutė", + "column": "Stulpelis", + "color": "Spalva", + "panel-settings": "Skydelio nustatymai", + "background-color": "Fono spalva", + "gpio-switches": "GPIO jungikliai", + "no-gpio-switches": "GPIO jungikliai nesukonfigūruoti", + "add-gpio-switch": "Pridėti GPIO jungiklį", + "gpio-status-request": "GPIO būsenos užklausa", + "method-name": "Metodo pavadinimas", + "method-body": "Metodo turinys", + "gpio-status-change-request": "GPIO būsenos keitimo užklausa", + "parse-gpio-status-function": "GPIO būsenos analizės funkcija", + "gpio-leds": "GPIO šviesos diodai", + "no-gpio-leds": "GPIO šviesos diodai nesukonfigūruoti", + "add-gpio-led": "Pridėti GPIO šviesos diodą" + }, + "html-card": { + "html": "HTML", + "css": "CSS" + }, + "input-widgets": { + "attribute-not-allowed": "Atributo parametras negali būti naudojamas šiame valdiklyje", + "blocked-location": "Geolokacija yra užblokuota jūsų naršyklėje", + "claim-device": "Priskirti įrenginį", + "claim-failed": "Nepavyko priskirti įrenginio!", + "claim-not-found": "Įrenginys nerastas!", + "claim-successful": "Įrenginys sėkmingai priskirtas!", + "date": "Data", + "device-name": "Įrenginio pavadinimas", + "device-name-required": "Įrenginio pavadinimas yra privalomas", + "discard-changes": "Atmesti pakeitimus", + "entity-attribute-required": "Objekto atributas yra privalomas", + "entity-coordinate-required": "Reikalingi abu laukai – platuma ir ilguma", + "entity-timeseries-required": "Objekto laiko seka yra privaloma", + "get-location": "Gauti dabartinę vietą", + "invalid-date": "Neteisinga data", + "latitude": "Platuma", + "longitude": "Ilguma", + "min-value-error": "Minimali reikšmė yra {{value}}", + "max-value-error": "Maksimali reikšmė yra {{value}}", + "not-allowed-entity": "Pasirinktas objektas negali turėti bendrų atributų", + "no-attribute-selected": "Nepasirinktas joks atributas", + "no-datakey-selected": "Nepasirinktas joks duomenų raktas", + "no-coordinate-specified": "Nenurodytas duomenų raktas platumai/ilgumai", + "no-entity-selected": "Nepasirinktas joks objektas", + "no-image": "Nėra paveikslėlio", + "no-support-geolocation": "Jūsų naršyklė nepalaiko geolokacijos", + "no-support-web-camera": "Jūsų naršyklė nepalaiko kamerų", + "enable-https-use-widget": "Įjunkite HTTPS, kad galėtumėte naudoti šį valdiklį", + "no-found-your-camera": "Kamera nerasta", + "no-permission-camera": "Naudotojas atmetė prieigą / svetainei nesuteikta leidimo naudoti kamerą", + "no-timeseries-selected": "Nepasirinkta jokia laiko seka", + "secret-key": "Slaptasis raktas", + "secret-key-required": "Slaptasis raktas yra privalomas", + "switch-attribute-value": "Perjungti objekto atributo reikšmę", + "switch-camera": "Perjungti kamerą", + "switch-timeseries-value": "Perjungti objekto laiko sekos reikšmę", + "take-photo": "Padaryti nuotrauką", + "time": "Laikas", + "timeseries-not-allowed": "Laiko sekos parametras negali būti naudojamas šiame valdiklyje", + "update-failed": "Atnaujinimas nepavyko", + "update-successful": "Atnaujinimas sėkmingas", + "update-attribute": "Atnaujinti atributą", + "update-timeseries": "Atnaujinti laiko seką", + "value": "Reikšmė", + "general-settings": "Bendrieji nustatymai", + "widget-title": "Valdiklio pavadinimas", + "claim-button-label": "Priskyrimo mygtuko etiketė", + "show-secret-key-field": "Rodyti laukelį „Slaptasis raktas“", + "labels-settings": "Etikečių nustatymai", + "show-labels": "Rodyti etiketes", + "device-name-label": "Etiketė įrenginio pavadinimo laukui", + "secret-key-label": "Etiketė slaptojo rakto laukui", + "messages-settings": "Pranešimų nustatymai", + "claim-device-success-message": "Tekstas, rodomas sėkmingai priskyrus įrenginį", + "claim-device-not-found-message": "Tekstas, rodomas, kai įrenginys nerastas", + "claim-device-failed-message": "Tekstas, rodomas nepavykus priskirti įrenginio", + "claim-device-name-required-message": "Klaidos pranešimas „Įrenginio pavadinimas privalomas“", + "claim-device-secret-key-required-message": "Klaidos pranešimas „Slaptasis raktas privalomas“", + "show-label": "Rodyti etiketę", + "label": "Etiketė", + "required": "Privaloma", + "required-error-message": "Klaidos pranešimas „Privaloma“", + "show-result-message": "Rodyti rezultatų pranešimą", + "integer-field-settings": "Sveikojo skaičiaus lauko nustatymai", + "min-value": "Minimali reikšmė", + "max-value": "Maksimali reikšmė", + "double-field-settings": "Skaičiaus su kableliu lauko nustatymai", + "text-field-settings": "Teksto lauko nustatymai", + "min-length": "Minimalus ilgis", + "max-length": "Maksimalus ilgis", + "checkbox-settings": "Žymimojo langelio nustatymai", + "true-label": "Pažymėto langelio etiketė", + "false-label": "Nepažymėto langelio etiketė", + "image-input-settings": "Paveikslėlio įvesties nustatymai", + "display-preview": "Rodyti peržiūrą", + "display-clear-button": "Rodyti išvalymo mygtuką", + "display-apply-button": "Rodyti taikymo mygtuką", + "display-discard-button": "Rodyti atmetimo mygtuką", + "datetime-field-settings": "Datos/laiko lauko nustatymai", + "display-time-input": "Rodyti laiko įvedimo lauką", + "latitude-key-name": "Platumos rakto pavadinimas", + "longitude-key-name": "Ilgumos rakto pavadinimas", + "show-get-location-button": "Rodyti mygtuką „Gauti dabartinę vietą“", + "use-high-accuracy": "Naudoti didelį tikslumą", + "location-fields-settings": "Vietos laukų nustatymai", + "latitude-label": "Etiketė platumai", + "longitude-label": "Etiketė ilgumai", + "input-fields-alignment": "Įvesties laukų lygiavimas", + "input-fields-alignment-column": "Stulpeliu (numatytasis)", + "input-fields-alignment-row": "Eilute", + "layout": "Išdėstymas", + "row-gap": "Tarpas tarp eilučių (pikseliais)", + "column-gap": "Tarpas tarp stulpelių (pikseliais)", + "latitude-field-required": "Platumos laukas yra privalomas", + "longitude-field-required": "Ilgumos laukas yra privalomas", + "attribute-settings": "Atributų nustatymai", + "widget-mode": "Valdiklio režimas", + "widget-mode-update-attribute": "Atnaujinti atributą", + "widget-mode-update-timeseries": "Atnaujinti laiko seką", + "attribute-scope": "Atributo sritis", + "attribute-scope-server": "Serverio atributas", + "attribute-scope-shared": "Bendras atributas", + "value-required": "Reikšmė privaloma", + "image-settings": "Paveikslėlio nustatymai", + "image-format": "Paveikslėlio formatas", + "image-format-jpeg": "JPEG", + "image-format-png": "PNG", + "image-format-webp": "WEBP", + "image-quality": "Paveikslėlio kokybė (nuostolinis glaudinimas, pvz., JPEG ar WEBP)", + "max-image-width": "Maksimalus paveikslėlio plotis", + "max-image-height": "Maksimalus paveikslėlio aukštis", + "action-buttons": "Veiksmo mygtukai", + "show-action-buttons": "Rodyti veiksmo mygtukus", + "update-all-values": "Atnaujinti visas reikšmes, o ne tik pakeistas", + "save-button-label": "Mygtuko „IŠSAUGOTI“ etiketė", + "reset-button-label": "Mygtuko „ATŠAUKTI“ etiketė", + "group-settings": "Grupės nustatymai", + "show-group-title": "Rodyti pavadinimą laukų grupei, susijusiai su skirtingais objektais", + "group-title": "Grupės pavadinimas", + "fields-alignment": "Laukų lygiavimas", + "fields-alignment-row": "Eilute (numatytasis)", + "fields-alignment-column": "Stulpeliu", + "fields-in-row": "Laukų skaičius eilutėje", + "option-value": "Reikšmė („null“ – tuščiam pasirinkimui)", + "option-label": "Etiketė", + "hide-input-field": "Slėpti įvesties lauką", + "datakey-type": "Duomenų rakto tipas", + "datakey-type-server": "Serverio atributas (numatytasis)", + "datakey-type-shared": "Bendras atributas", + "datakey-type-timeseries": "Laiko seka", + "datakey-value-type": "Duomenų rakto reikšmės tipas", + "datakey-value-type-string": "Tekstas", + "datakey-value-type-double": "Skaičius su kableliu", + "datakey-value-type-integer": "Sveikasis skaičius", + "datakey-value-type-json": "JSON", + "datakey-value-type-boolean-checkbox": "Loginis (žymimasis langelis)", + "datakey-value-type-boolean-switch": "Loginis (jungiklis)", + "datakey-value-type-date-time": "Data ir laikas", + "datakey-value-type-date": "Data", + "datakey-value-type-time": "Laikas", + "datakey-value-type-select": "Pasirinkimas", + "datakey-value-type-radio": "Pasirinkimo mygtukas", + "datakey-value-type-color": "Spalva", + "value-is-required": "Reikšmė yra privaloma", + "ability-to-edit-attribute": "Atributo redagavimo galimybė", + "ability-to-edit-attribute-editable": "Redaguojamas (numatytasis)", + "ability-to-edit-attribute-disabled": "Išjungtas", + "ability-to-edit-attribute-readonly": "Tik skaitymui", + "disable-on-datakey-name": "Išjungti, jei kito duomenų rakto reikšmė yra neteisinga (nurodykite rakto pavadinimą)", + "field-appearance": "Lauko išvaizda", + "appearance-fill": "Užpildytas", + "appearance-outline": "Kontūrinis", + "subscript-sizing": "Indekso dydis", + "subscript-sizing-fixed": "Fiksuotas", + "subscript-sizing-dynamic": "Dinaminis", + "slide-toggle-settings": "Perjungimo slankiklio nustatymai", + "slide-toggle-label-position": "Perjungimo etiketės padėtis", + "slide-toggle-label-position-after": "Po", + "slide-toggle-label-position-before": "Prieš", + "select-options": "Pasirinkimo parinktys", + "no-select-options": "Pasirinkimo parinktys nesukonfigūruotos", + "add-select-option": "Pridėti pasirinkimo parinktį", + "numeric-field-settings": "Skaitinio lauko nustatymai", + "step-interval": "Reikšmių žingsnio intervalas", + "error-messages": "Klaidų pranešimai", + "min-value-error-message": "Klaidos pranešimas „Minimali reikšmė“", + "max-value-error-message": "Klaidos pranešimas „Maksimali reikšmė“", + "invalid-date-error-message": "Klaidos pranešimas „Neteisinga data“", + "invalid-JSON-error-message": "Klaidos pranešimas „Neteisingas JSON formatas“", + "icon-settings": "Piktogramos nustatymai", + "dialog-editor-settings": "Dialogo redaktoriaus nustatymai", + "use-custom-icon": "Naudoti tinkintą piktogramą", + "input-cell-icon": "Piktograma prieš įvesties laukelį", + "value-conversion-settings": "Reikšmės konvertavimo nustatymai", + "get-value-settings": "Reikšmės gavimo nustatymai", + "use-get-value-function": "Naudoti getValue funkciją", + "get-value-function": "getValue funkcija", + "set-value-settings": "Reikšmės nustatymo nustatymai", + "use-set-value-function": "Naudoti setValue funkciją", + "set-value-function": "setValue funkcija", + "json-invalid": "JSON reikšmė turi neteisingą formatą", + "title": "Pavadinimas", + "cancel-button-label": "Mygtuko „Atšaukti“ etiketė", + "radio-button-settings": "Pasirinkimo mygtuko nustatymai", + "color": "Spalva", + "columns": "Stulpeliai", + "radio-options": "Pasirinkimo parinktys", + "no-radio-options": "Pasirinkimo parinktys nesukonfigūruotos", + "add-radio-option": "Pridėti pasirinkimo parinktį", + "radio-label-position": "Etiketės padėtis", + "radio-label-position-before": "Prieš", + "radio-label-position-after": "Po" + }, + "invalid-qr-code-text": "Neteisingas įvestas tekstas QR kodui. Įvestis turi būti teksto tipo", + "qr-code": { + "use-qr-code-text-function": "Naudoti QR kodo teksto funkciją", + "qr-code-text-pattern": "QR kodo teksto šablonas (pvz. '${entityName} | ${keyName} - papildomas tekstas.')", + "qr-code-text-pattern-hint": "QR kodo teksto šablonas naudoja pirmo rasto rakto reikšmę iš objektų, esančių objekto pseudonime.", + "qr-code-text-pattern-required": "QR kodo teksto šablonas yra privalomas.", + "qr-code-text-function": "QR kodo teksto funkcija" + }, + "label-widget": { + "label-pattern": "Šablonas", + "label-pattern-hint": "Patarimas: pvz. 'Tekstas ${keyName} vienetai.' arba ${#<rakto indeksas>} vienetai'", + "label-pattern-required": "Šablonas yra privalomas", + "label-position": "Padėtis (procentais fone)", + "x-pos": "X", + "y-pos": "Y", + "background-color": "Fono spalva", + "font-settings": "Šrifto nustatymai", + "background-image": "Fono paveikslėlis", + "labels": "Etiketės", + "no-labels": "Etiketės nesukonfigūruotos", + "add-label": "Pridėti etiketę" + }, + "navigation": { + "title": "Pavadinimas", + "navigation-path": "Naršymo kelias", + "filter-type": "Filtro tipas", + "filter-type-all": "Visi elementai", + "filter-type-include": "Įtraukti elementus", + "filter-type-exclude": "Išskirti elementus", + "items": "Elementai", + "enter-urls-to-filter": "Įveskite URL filtravimui..." + }, + "persistent-table": { + "rpc-id": "RPC ID", + "message-type": "Pranešimo tipas", + "method": "Metodas", + "params": "Parametrai", + "created-time": "Sukūrimo laikas", + "expiration-time": "Galiojimo pabaigos laikas", + "retries": "Bandymų skaičius", + "status": "Būsena", + "filter": "Filtras", + "refresh": "Atnaujinti", + "add": "Pridėti RPC užklausą", + "details": "Išsamiau", + "delete": "Ištrinti", + "delete-request-title": "Ištrinti nuolatinę RPC užklausą", + "delete-request-text": "Ar tikrai norite ištrinti šią užklausą?", + "details-title": "Išsami informacija RPC ID: ", + "additional-info": "Papildoma informacija", + "response": "Atsakas", + "any-status": "Bet kuri būsena", + "rpc-status-list": "RPC būsenų sąrašas", + "no-request-prompt": "Nėra rodomų užklausų", + "send-request": "Siųsti užklausą", + "add-title": "Sukurti nuolatinę RPC užklausą", + "method-error": "Metodas yra privalomas.", + "timeout-error": "Minimalus laukimo laikas yra 5000 (5 sekundės).", + "white-space-error": "Tarpai neleidžiami.", + "rpc-status": { + "QUEUED": "EILĖJE", + "SENT": "IŠSIŲSTA", + "DELIVERED": "PRISTATYTA", + "SUCCESSFUL": "SĖKMINGA", + "TIMEOUT": "PASIBAIGĖ LAIKAS", + "EXPIRED": "NEBEGALIOJA", + "FAILED": "NEPAVYKO" + }, + "rpc-search-status-all": "VISOS", + "message-types": { + "false": "Dvipusis", + "true": "Vienpusis" + }, + "general-settings": "Bendrieji nustatymai", + "enable-filter": "Įjungti filtrą", + "enable-sticky-header": "Rodyti antraštę slenkant", + "enable-sticky-action": "Rodyti veiksmų stulpelį slenkant", + "display-request-details": "Rodyti užklausos detales", + "allow-send-request": "Leisti siųsti RPC užklausą", + "allow-delete-request": "Leisti ištrinti RPC užklausą", + "columns-settings": "Stulpelių nustatymai", + "display-columns": "Rodytini stulpeliai", + "column": "Stulpelis", + "no-columns-found": "Nerasta jokių stulpelių", + "no-columns-matching": "„{{column}}“ nerasta." + }, + "range-chart": { + "chart": "Diagrama", + "data-zoom": "Duomenų mastelio keitimas", + "range-chart-appearance": "Intervalinės diagramos išvaizda", + "range-colors": "Intervalo spalvos", + "out-of-range-color": "Už intervalo ribų spalva", + "show-range-thresholds": "Rodyti intervalo ribas", + "range-thresholds-settings": "Intervalo ribų nustatymai", + "fill-area": "Užpildymo sritis", + "fill-area-opacity": "Užpildymo srities nepermatomumas", + "range-chart-style": "Intervalinės diagramos stilius" + }, + "knob": { + "behavior": "Elgsena", + "initial-value": "Pradinė reikšmė", + "initial-value-hint": "Veiksmas, skirtas gauti pradinę rankenėlės reikšmę.", + "on-value-change": "Pasikeitus reikšmei", + "on-value-change-hint": "Veiksmas, vykdomas, kai rankenėlės reikšmė pasikeičia.", + "range": "Intervalas", + "min": "min", + "max": "maks", + "value": "Reikšmė", + "fallback-initial-value": "Atsarginė pradinė reikšmė" + }, + "rpc": { + "value-settings": "Reikšmės nustatymai", + "initial-value": "Pradinė reikšmė", + "retrieve-value-settings": "Įjungimo/išjungimo reikšmės gavimo nustatymai", + "retrieve-value-method": "Reikšmės gavimo metodas", + "retrieve-value-method-none": "Negauti reikšmės", + "retrieve-value-method-rpc": "Gauti reikšmę naudojant RPC metodą", + "retrieve-value-method-attribute": "Prenumeruoti atributą", + "retrieve-value-method-timeseries": "Prenumeruoti laiko seką", + "attribute-value-key": "Atributo raktas", + "timeseries-value-key": "Laiko sekos raktas", + "get-value-method": "RPC reikšmės gavimo metodas", + "parse-value-function": "Reikšmės analizės funkcija", + "update-value-settings": "Reikšmės atnaujinimo nustatymai", + "set-value-method": "RPC reikšmės nustatymo metodas", + "convert-value-function": "Reikšmės konvertavimo funkcija", + "rpc-settings": "RPC nustatymai", + "request-timeout": "RPC užklausos laukimo laikas (ms)", + "persistent-rpc-settings": "Nuolatinės RPC nustatymai", + "request-persistent": "Nuolatinė RPC užklausa", + "persistent-polling-interval": "Apklausos intervalas (ms) nuolatinei RPC komandai gauti", + "common-settings": "Bendrieji nustatymai", + "switch-title": "Jungiklio pavadinimas", + "show-on-off-labels": "Rodyti įjungta/išjungta etiketes", + "slide-toggle-label": "Perjungimo etiketė", + "label-position": "Etiketės padėtis", + "label-position-before": "Prieš", + "label-position-after": "Po", + "slider-color": "Slankiklio spalva", + "slider-color-primary": "Pagrindinė", + "slider-color-accent": "Akcentinė", + "slider-color-warn": "Įspėjamoji", + "button-style": "Mygtuko stilius", + "button-raised": "Iškilęs mygtukas", + "button-primary": "Pagrindinė spalva", + "button-background-color": "Mygtuko fono spalva", + "button-text-color": "Mygtuko teksto spalva", + "widget-title": "Valdiklio pavadinimas", + "button-label": "Mygtuko etiketė", + "device-attribute-scope": "Įrenginio atributo sritis", + "server-attribute": "Serverio atributas", + "shared-attribute": "Bendras atributas", + "device-attribute-parameters": "Įrenginio atributo parametrai", + "is-one-way-command": "Vienpusė komanda", + "rpc-method": "RPC metodas", + "rpc-method-params": "RPC metodo parametrai", + "show-rpc-error": "Rodyti RPC komandos vykdymo klaidą", + "led-title": "LED pavadinimas", + "led-color": "LED spalva", + "check-status-settings": "Būsenos tikrinimo nustatymai", + "perform-rpc-status-check": "Vykdyti RPC įrenginio būsenos patikrinimą", + "retrieve-led-status-value-method": "LED būsenos gavimo metodas", + "led-status-value-attribute": "Įrenginio atributas, kuriame saugoma LED būsena", + "led-status-value-timeseries": "Įrenginio laiko seka, kurioje saugoma LED būsena", + "check-status-method": "RPC įrenginio būsenos tikrinimo metodas", + "parse-led-status-value-function": "LED būsenos reikšmės analizės funkcija", + "knob-title": "Rankenėlės pavadinimas" + }, + "maps": { + "map-type": { + "type": "Žemėlapio tipas", + "map": "Žemėlapis", + "image": "Paveikslėlis" + }, + "image": { + "image-source": "Paveikslėlio šaltinis", + "image-source-image": "Paveikslėlis", + "image-source-entity-key": "Objekto raktas", + "source-entity-alias": "Šaltinio objekto pseudonimas", + "image-url-key": "Paveikslėlio URL raktas", + "image-url-key-required": "Paveikslėlio URL raktas yra privalomas" + }, + "control": { + "map-controls": "Žemėlapio valdikliai", + "position": "Padėtis", + "position-topleft": "Viršuje kairėje", + "position-topright": "Viršuje dešinėje", + "position-bottomleft": "Apačioje kairėje", + "position-bottomright": "Apačioje dešinėje", + "zoom-actions": "Mastelio keitimo veiksmai", + "zoom-scroll": "Slinkimas", + "zoom-double-click": "Dvigubas paspaudimas", + "zoom-control-buttons": "Valdymo mygtukai", + "scale": "Mastelis", + "scale-metric": "Metrinis", + "scale-imperial": "Imperinis", + "switch-to-drag-mode-using-button": "Perjungti į tempimo režimą naudojant mygtuką" + }, + "timeline": { + "control-panel": "Laiko juostos valdymo skydelis", + "time-step": "Laiko žingsnis", + "speed-options": "Greičio parinktys", + "timestamp": "Laiko žyma", + "snap-to-real-location": "Pririšti prie tikros vietos", + "location-snap-filter-function": "Vietos pririšimo filtravimo funkcija", + "no-trips-data-available": "Kelionių duomenų nėra" + }, + "map-action": { + "map-action-buttons": "Žemėlapio veiksmų mygtukai", + "label": "Etiketė", + "icon": "Piktograma", + "color": "Spalva", + "action": "Veiksmas", + "add-button": "Pridėti mygtuką", + "no-action-buttons-configured": "Veiksmų mygtukai nesukonfigūruoti", + "remove-action-button": "Pašalinti veiksmų mygtuką", + "map-action-button": "Žemėlapio veiksmų mygtukas", + "button-requires": "Mygtukui reikalinga etiketė arba piktograma" + }, + "common": { + "common-map-settings": "Bendrieji žemėlapio nustatymai", + "fit-map-bounds": "Pritaikyti žemėlapio ribas, kad apimtų visus žymeklius", + "default-map-center-position": "Numatytoji žemėlapio centro padėtis", + "default-map-zoom-level": "Numatytasis žemėlapio mastelio lygis", + "entities-limit": "Įkeltinų objektų riba" + }, + "layer": { + "label": "Etiketė", + "layer": "Sluoksnis", + "layers": "Sluoksniai", + "map-layers": "Žemėlapio sluoksniai", + "add-layer": "Pridėti sluoksnį", + "layer-settings": "Sluoksnio nustatymai", + "remove-layer": "Pašalinti sluoksnį", + "no-layers": "Sluoksniai nesukonfigūruoti", + "roadmap": "Kelio žemėlapis", + "satellite": "Palydovinis", + "hybrid": "Hibridinis", + "reference": { + "reference-layer": "Etaloninis sluoksnis", + "no-layer": "Nėra sluoksnio", + "openstreetmap-hybrid": "OpenStreetMap hibridinis", + "world-edition-hybrid": "World Edition hibridinis", + "enhanced-contrast-hybrid": "Padidinto kontrasto hibridinis" }, - "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ė" + "provider": { + "provider": "Tiekėjas", + "openstreet": { + "title": "OpenStreet", + "mapnik": "Mapnik", + "hot": "HOT", + "esri-street": "WorldStreetMap", + "esri-topo": "WorldTopoMap", + "esri-imagery": "WorldImagery", + "cartodb-positron": "Positron", + "cartodb-dark-matter": "DarkMatter" + }, + "google": { + "title": "Google", + "roadmap": "Kelio žemėlapis", + "satellite": "Palydovinis", + "hybrid": "Hibridinis", + "terrain": "Reljefas" + }, + "here": { + "title": "HERE", + "normal-day": "Įprasta diena", + "normal-night": "Įprasta naktis", + "hybrid-day": "Hibridinė diena", + "terrain-day": "Reljefinė diena" + }, + "tencent": { + "title": "Tencent", + "normal": "Įprastas", + "satellite": "Palydovinis", + "terrain": "Reljefinis" + }, + "custom": { + "title": "Tinkintas", + "tile-url": "Plytelės URL" + } }, - "sort-order": { - "asc": "Didėjanti", - "desc": "Mažėjanti", - "none": "Nėra" + "credentials": { + "credentials": "Prisijungimo duomenys", + "api-key": "API raktas" + } + }, + "overlays": { + "overlays": "Perdangos", + "overlays-hint": "Sukonfigūruokite duomenų šaltinius, išvaizdą, elgseną, redagavimo parinktis ir objektų grupavimą žemėlapyje", + "trips": "Kelionės", + "markers": "Žymekliai", + "polygons": "Daugiakampiai", + "circles": "Apskritimai" + }, + "data-layer": { + "source": "Šaltinis", + "filter": "Filtras", + "additional-data-keys": "Papildomi duomenų raktai", + "additional-datasources": "Papildomi duomenų šaltiniai", + "additional-datasources-hint": "Duomenų šaltinis, skirtas pasiekti atributus ar telemetriją iš objektų, nerodomų žemėlapyje; gali būti naudojamas žemėlapio perdangų funkcijose.", + "more-datasources": "Daugiau duomenų šaltinių", + "data-keys": "Duomenų raktai", + "add-datasource": "Pridėti duomenų šaltinį", + "no-datasources": "Duomenų šaltiniai nesukonfigūruoti", + "remove-datasource": "Pašalinti duomenų šaltinį", + "behavior": "Elgsena", + "on-click": "Paspaudus", + "on-click-hint": "Veiksmas, vykdomas, kai naudotojas paspaudžia ant žemėlapio elemento.", + "groups": "Grupės", + "groups-hint": "Grupių pavadinimų sąrašas, priskirtas perdangai, naudojamas jos matomumui žemėlapyje keisti.", + "color": "Spalva", + "color-settings": "Spalvos nustatymai", + "color-type-constant": "Pastovi", + "color-type-range": "Intervalas", + "color-type-function": "Funkcija", + "color-range-source-key": "Spalvų intervalo šaltinio raktas", + "color-range-source-key-required": "Spalvų intervalo šaltinio raktas yra privalomas", + "color-range": "Spalvų intervalas", + "color-function": "Spalvos funkcija", + "label": "Etiketė", + "tooltip": "Patarimas", + "pattern-type-pattern": "Šablonas", + "pattern-type-function": "Funkcija", + "label-pattern": "Etiketė (šablonų pavyzdžiai: '${entityName}', '${entityName}: (Tekstas ${keyName} vienetai.)')", + "label-function": "Etiketės funkcija", + "tooltip-pattern": "Patarimas (pvz. 'Tekstas ${keyName} vienetai.' arba Nuorodos tekstas)", + "tooltip-function": "Patarimo funkcija", + "tooltip-trigger": "Patarimo aktyvatorius", + "tooltip-trigger-click": "Rodyti patarimą paspaudus", + "tooltip-trigger-hover": "Rodyti patarimą užvedus žymeklį", + "auto-close-tooltips": "Automatiškai uždaryti patarimus", + "tooltip-offset": "Patarimo poslinkis", + "tooltip-offset-horizontal": "Horizontalus", + "tooltip-offset-vertical": "Vertikalus", + "tooltip-tag-actions": "Žymų veiksmai", + "add-tooltip-tag-action": "Pridėti žymos veiksmą", + "edit-tooltip-tag-action": "Redaguoti žymos veiksmą", + "remove-tooltip-tag-action": "Pašalinti žymos veiksmą", + "action-add": "Pridėti", + "action-edit": "Redaguoti", + "action-move": "Perkelti", + "action-remove": "Pašalinti", + "edit-instruments": "Įrankiai", + "persist-location-attribute-scope": "Atributo sritis vietos išsaugojimui", + "enable-snapping": "Įjungti pritraukimą prie kitų viršūnių tiksliam braižymui", + "enable-snapping-hint": "Automatiškai sulygiuoja naujus taškus su esamomis formomis, kad braižymas būtų tikslesnis.", + "drag-drop-mode": "Vilkimo režimas", + "trip": { + "no-trips": "Kelionės nesukonfigūruotos", + "add-trip": "Pridėti kelionę", + "trip-configuration": "Kelionės konfigūracija", + "remove-trip": "Pašalinti kelionę" }, - "details-mode": { - "on-row-click": "Spustelėjus eilutėje", - "on-action-button-click": "Paspaudus informacijos mygtuką", - "disabled": "Išjungta" + "marker": { + "marker": "Žymeklis", + "latitude-key": "Platumos raktas", + "longitude-key": "Ilgumos raktas", + "x-pos-key": "X padėties raktas", + "y-pos-key": "Y padėties raktas", + "latitude-key-required": "Platumos raktas yra privalomas", + "longitude-key-required": "Ilgumos raktas yra privalomas", + "x-pos-key-required": "X padėties raktas yra privalomas", + "y-pos-key-required": "Y padėties raktas yra privalomas", + "no-markers": "Žymekliai nesukonfigūruoti", + "add-marker": "Pridėti žymeklį", + "marker-configuration": "Žymeklio konfigūracija", + "remove-marker": "Pašalinti žymeklį", + "marker-type": "Žymeklio tipas", + "marker-type-shape": "Forma", + "marker-type-icon": "Piktograma", + "marker-type-image": "Paveikslėlis", + "shape": "Forma", + "icon": "Piktograma", + "image": "Paveikslėlis", + "marker-shapes": "Žymeklio formos", + "marker-icon": "Žymeklio piktograma", + "marker-appearance": "Žymeklio išvaizda", + "marker-image": "Žymeklio paveikslėlis", + "marker-image-type-image": "Paveikslėlis", + "marker-image-type-function": "Funkcija", + "custom-marker-image-size": "Tinkintas žymeklio paveikslėlio dydis", + "marker-image-function": "Žymeklio paveikslėlio funkcija", + "marker-images": "Žymeklio paveikslėliai", + "marker-offset": "Žymeklio poslinkis", + "offset-horizontal": "Horizontalus", + "offset-vertical": "Vertikalus", + "rotate-marker": "Pasukti žymeklį", + "offset-angle": "Poslinkio kampas", + "position-conversion": "Padėties konversija", + "position-conversion-function": "Padėties konversijos funkcija, turi grąžinti x,y koordinates (dvigubo tipo) nuo 0 iki 1", + "clustering": { + "use-map-markers-clustering": "Naudoti žemėlapio žymeklių grupavimą", + "zoom-on-cluster-click": "Priartinti paspaudus ant grupės", + "max-zoom": "Didžiausias mastelio lygis, kai žymeklis gali priklausyti grupei (0–18)", + "max-radius": "Didžiausias spindulys, kurį apima grupė", + "zoom-animation": "Žymeklių animacija keičiat mastelį", + "bounds-on-cluster-mouse-over": "Rodyti žymeklių ribas užvedus pelę ant grupės", + "spiderfy-max-zoom-level": "Išskleisti grupės žymeklius esant didžiausiam masteliui (kad matytųsi visi)", + "load-optimization": "Įkėlimo optimizavimas", + "chunked-load": "Naudoti dalinį įkėlimą, kad puslapis neužstrigtų", + "lazy-load": "Naudoti uždelstą įkėlimą žymekliams pridėti", + "use-cluster-marker-color-function": "Naudoti grupės žymeklių spalvos funkciją", + "marker-color-function": "Žymeklio spalvos funkcija" + }, + "edit": "Redaguoti žymeklį", + "remove-marker-for": "Pašalinti žymeklį objektui '{{entityName}}'", + "place-marker": "Padėti žymeklį", + "place-marker-hint": "Spustelėkite, kad padėtumėte žymeklį", + "place-marker-hint-with-entity": "Spustelėkite, kad padėtumėte objektą '{{entityName}}'" }, - "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" + "path": { + "path": "Kelias", + "path-decorator": "Kelio dekoratorius", + "decorator-symbol": "Dekoratoriaus simbolis", + "decorator-symbol-arrow-head": "Rodyklė", + "decorator-symbol-dash": "Brūkšnys", + "decorator-arrangement": "Dekoratoriaus išdėstymas", + "decorator-offset": "Pradžia", + "decorator-end-offset": "Pabaiga", + "decorator-repeat": "Kartojimas" + }, + "points": { + "points": "Taškai", + "point-tooltip": "Taško patarimas" }, - "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" + "shape": { + "fill": "Užpildymas", + "fill-type-color": "Spalva", + "fill-type-stripe": "Dryžis", + "fill-type-image": "Paveikslėlis", + "color": "Spalva", + "stripe": "Dryžis", + "image": "Paveikslėlis", + "stroke": "Kontūras", + "fill-image": "Užpildo paveikslėlis", + "fill-image-type-image": "Paveikslėlis", + "fill-image-type-function": "Funkcija", + "preserve-aspect-ratio": "Išlaikyti proporcijas", + "opacity": "Nepermatomumas", + "angle": "Pasukimo kampas", + "scale": "Mastelis", + "fill-image-function": "Formos užpildo paveikslėlio funkcija", + "fill-images": "Formos užpildo paveikslėliai", + "stripe-pattern": "Dryžių raštas", + "first-stripe": "Pirmas dryžis", + "second-stripe": "Antras dryžis" }, - "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", - "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" + "polygon": { + "polygon-key": "Daugiakampio raktas", + "polygon-key-required": "Daugiakampio raktas yra privalomas", + "no-polygons": "Daugiakampiai nesukonfigūruoti", + "add-polygon": "Pridėti daugiakampį", + "polygon-configuration": "Daugiakampio konfigūracija", + "remove-polygon": "Pašalinti daugiakampį", + "edit": "Redaguoti daugiakampį", + "remove-polygon-for": "Pašalinti daugiakampį objektui '{{entityName}}'", + "cut": "Iškirpti daugiakampio sritį", + "rotate": "Pasukti daugiakampį", + "draw-rectangle": "Nubrėžti stačiakampį", + "draw-polygon": "Nubrėžti daugiakampį", + "polygon-place-first-point-cut-hint": "Spustelėkite, kad padėtumėte pirmą tašką", + "continue-polygon-cut-hint": "Spustelėkite, kad tęstumėte braižymą", + "finish-polygon-cut-hint": "Spustelėkite pirmą žymeklį, kad baigtumėte ir išsaugotumėte", + "polygon-place-first-point-hint": "Daugiakampis: spustelėkite, kad padėtumėte pirmą tašką", + "polygon-place-first-point-hint-with-entity": "Daugiakampis objektui '{{entityName}}': spustelėkite, kad padėtumėte pirmą tašką", + "continue-polygon-hint": "Daugiakampis: spustelėkite, kad tęstumėte braižymą", + "continue-polygon-hint-with-entity": "Daugiakampis objektui '{{entityName}}': spustelėkite, kad tęstumėte braižymą", + "finish-polygon-hint": "Daugiakampis: spustelėkite pirmą žymeklį, kad baigtumėte braižymą", + "finish-polygon-hint-with-entity": "Daugiakampis objektui '{{entityName}}': spustelėkite pirmą žymeklį, kad baigtumėte ir išsaugotumėte", + "rectangle-place-first-point-hint": "Stačiakampis: spustelėkite, kad padėtumėte pirmą tašką", + "rectangle-place-first-point-hint-with-entity": "Stačiakampis objektui '{{entityName}}': spustelėkite, kad padėtumėte pirmą tašką", + "finish-rectangle-hint": "Stačiakampis: spustelėkite, kad baigtumėte braižymą", + "finish-rectangle-hint-with-entity": "Stačiakampis objektui '{{entityName}}': spustelėkite, kad baigtumėte ir išsaugotumėte" }, - "value-type": { - "value-type": "Value type", - "string": "String", - "numeric": "Numeric", - "boolean": "Boolean", - "date-time": "Datetime" + "circle": { + "circle-key": "Apskritimo raktas", + "circle-key-required": "Apskritimo raktas yra privalomas", + "no-circles": "Apskritimai nesukonfigūruoti", + "add-circle": "Pridėti apskritimą", + "circle-configuration": "Apskritimo konfigūracija", + "remove-circle": "Pašalinti apskritimą", + "edit": "Redaguoti apskritimą", + "remove-circle-for": "Pašalinti apskritimą objektui '{{entityName}}'", + "draw-circle": "Nubrėžti apskritimą", + "place-circle-center-hint-with-entity": "Apskritimas objektui '{{entityName}}': spustelėkite, kad padėtumėte apskritimo centrą", + "place-circle-center-hint": "Apskritimas: spustelėkite, kad padėtumėte apskritimo centrą", + "finish-circle-hint-with-entity": "Apskritimas objektui '{{entityName}}': spustelėkite, kad baigtumėte ir išsaugotumėte", + "finish-circle-hint": "Apskritimas: spustelėkite, kad baigtumėte braižymą" }, - "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" + "select-entity": "Pasirinkti objektą", + "select-entity-hint": "Patarimas: po pasirinkimo spustelėkite žemėlapyje, kad nustatytumėte padėtį" + }, + "select-entity": "Pasirinkti objektą", + "select-entity-hint": "Patarimas: po pasirinkimo spustelėkite žemėlapyje, kad nustatytumėte padėtį", + "tooltips": { + "placeMarker": "Spustelėkite, kad padėtumėte objektą '{{entityName}}'", + "firstVertex": "Daugiakampis objektui '{{entityName}}': spustelėkite, kad padėtumėte pirmą tašką", + "firstVertex-cut": "Spustelėkite, kad padėtumėte pirmą tašką", + "continueLine": "Daugiakampis objektui '{{entityName}}': spustelėkite, kad tęstumėte braižymą", + "continueLine-cut": "Spustelėkite, kad tęstumėte braižymą", + "finishLine": "Spustelėkite bet kurį esamą žymeklį, kad baigtumėte", + "finishPoly": "Daugiakampis objektui '{{entityName}}': spustelėkite pirmą žymeklį, kad baigtumėte ir išsaugotumėte", + "finishPoly-cut": "Spustelėkite pirmą žymeklį, kad baigtumėte ir išsaugotumėte", + "finishRect": "Daugiakampis objektui '{{entityName}}': spustelėkite, kad baigtumėte ir išsaugotumėte", + "startCircle": "Apskritimas objektui '{{entityName}}': spustelėkite, kad padėtumėte apskritimo centrą", + "finishCircle": "Apskritimas objektui '{{entityName}}': spustelėkite, kad baigtumėte apskritimą", + "placeCircleMarker": "Spustelėkite, kad padėtumėte apskritimo žymeklį" + }, + "actions": { + "finish": "Baigti", + "cancel": "Atšaukti", + "removeLastVertex": "Pašalinti paskutinį tašką" + }, + "buttonTitles": { + "drawMarkerButton": "Padėti objektą", + "drawPolyButton": "Sukurti daugiakampį", + "drawLineButton": "Sukurti liniją", + "drawCircleButton": "Sukurti apskritimą", + "drawRectButton": "Sukurti stačiakampį", + "editButton": "Redagavimo režimas", + "dragButton": "Vilkimo režimas", + "cutButton": "Iškirpti daugiakampio sritį", + "deleteButton": "Pašalinti", + "drawCircleMarkerButton": "Sukurti apskritimo žymeklį", + "rotateButton": "Pasukti daugiakampį" + }, + "map-provider-settings": "Žemėlapio tiekėjo nustatymai", + "map-provider": "Žemėlapio tiekėjas", + "map-provider-google": "Google žemėlapiai", + "map-provider-openstreet": "OpenStreet žemėlapiai", + "map-provider-here": "HERE žemėlapiai", + "map-provider-image": "Paveikslėlio žemėlapis", + "map-provider-tencent": "Tencent žemėlapiai", + "openstreet-provider": "OpenStreet žemėlapių tiekėjas", + "openstreet-provider-mapnik": "OpenStreetMap.Mapnik (numatytasis)", + "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": "Naudoti pasirinktinį tiekėją", + "custom-provider-tile-url": "Pasirinktinio tiekėjo plytelių URL", + "google-maps-api-key": "Google Maps API raktas", + "default-map-type": "Numatytasis žemėlapio tipas", + "google-map-type-roadmap": "Keliai", + "google-map-type-satelite": "Palydovas", + "google-map-type-hybrid": "Hibridinis", + "google-map-type-terrain": "Reljefas", + "map-layer": "Žemėlapio sluoksnis", + "here-map-normal-day": "HERE.normalDay (numatytasis)", + "here-map-normal-night": "HERE.normalNight", + "here-map-hybrid-day": "HERE.hybridDay", + "here-map-terrain-day": "HERE.terrainDay", + "credentials": "Prisijungimo duomenys", + "here-app-id": "HERE programos ID", + "here-app-code": "HERE programos kodas", + "here-api-key": "HERE API raktas", + "here-use-new-version-api-3": "Naudoti API 3 versiją", + "tencent-maps-api-key": "Tencent Maps API raktas", + "tencent-map-type-roadmap": "Keliai", + "tencent-map-type-satelite": "Palydovas", + "tencent-map-type-hybrid": "Hibridinis", + "image-map-background": "Paveikslėlio žemėlapio fonas", + "image-map-background-from-entity-attribute": "Naudoti paveikslėlį iš objekto atributo kaip žemėlapio foną", + "image-url-source-entity-alias": "Paveikslėlio URL šaltinio objekto pseudonimas", + "image-url-source-entity-attribute": "Paveikslėlio URL šaltinio objekto atributas", + "common-map-settings": "Bendrieji žemėlapio nustatymai", + "x-pos-key-name": "X padėties rakto pavadinimas", + "y-pos-key-name": "Y padėties rakto pavadinimas", + "latitude-key-name": "Platumos rakto pavadinimas", + "longitude-key-name": "Ilgumos rakto pavadinimas", + "default-map-zoom-level": "Numatytasis priartinimo lygis (0–20)", + "default-map-center-position": "Numatytoji žemėlapio centro padėtis (0,0)", + "disable-scroll-zooming": "Išjungti priartinimą slenkant", + "disable-double-click-zooming": "Išjungti priartinimą dukart spustelėjus", + "disable-zoom-control-buttons": "Išjungti priartinimo valdymo mygtukus", + "fit-map-bounds": "Pritaikyti žemėlapio ribas, kad apimtų visus žymeklius", + "use-default-map-center-position": "Naudoti numatytąją žemėlapio centro padėtį", + "entities-limit": "Objektų įkėlimo riba", + "markers-settings": "Žymeklių nustatymai", + "marker-offset-x": "Žymeklio X poslinkis, lyginant su padėtimi (daugiklis žymeklio pločiui)", + "marker-offset-y": "Žymeklio Y poslinkis, lyginant su padėtimi (daugiklis žymeklio aukščiui)", + "position-function": "Padėties konversijos funkcija, grąžinanti x,y koordinates nuo 0 iki 1", + "draggable-marker": "Vilkiojamas žymeklis", + "label": "Etiketė", + "show-label": "Rodyti etiketę", + "use-label-function": "Naudoti etiketės funkciją", + "label-pattern": "Etiketė (pavyzdžiai: '${entityName}', '${entityName}: (Tekstas ${keyName} vienetai.)')", + "label-function": "Etiketės funkcija", + "tooltip": "Patarimas", + "show-tooltip": "Rodyti patarimą", + "show-tooltip-action": "Veiksmas, kai rodomas patarimas", + "show-tooltip-action-click": "Rodyti spustelėjus (numatytasis)", + "show-tooltip-action-hover": "Rodyti užvedus žymeklį", + "auto-close-tooltips": "Automatiškai uždaryti patarimus", + "use-tooltip-function": "Naudoti patarimo funkciją", + "tooltip-pattern": "Patarimas (pvz.: 'Tekstas ${keyName} vienetai.' arba Nuorodos tekstas)", + "tooltip-function": "Patarimo funkcija", + "tooltip-offset-x": "Patarimo X poslinkis (pagal žymeklio inkarą, dauginamas iš pločio)", + "tooltip-offset-y": "Patarimo Y poslinkis (pagal žymeklio inkarą, dauginamas iš aukščio)", + "color": "Spalva", + "use-color-function": "Naudoti spalvos funkciją", + "color-function": "Spalvos funkcija", + "marker-image": "Žymeklio paveikslėlis", + "use-marker-image-function": "Naudoti žymeklio paveikslėlio funkciją", + "custom-marker-image": "Pasirinktinis žymeklio paveikslėlis", + "custom-marker-image-size": "Pasirinktinio žymeklio paveikslėlio dydis (px)", + "marker-image-function": "Žymeklio paveikslėlio funkcija", + "marker-images": "Žymeklio paveikslėliai", + "polygon-settings": "Daugiakampio nustatymai", + "show-polygon": "Rodyti daugiakampį", + "polygon-key-name": "Daugiakampio rakto pavadinimas", + "enable-polygon-edit": "Įjungti daugiakampio redagavimą", + "polygon-label": "Daugiakampio etiketė", + "show-polygon-label": "Rodyti daugiakampio etiketę", + "use-polygon-label-function": "Naudoti daugiakampio etiketės funkciją", + "polygon-label-pattern": "Daugiakampio etiketė (pavyzdžiai: '${entityName}', '${entityName}: (Tekstas ${keyName} vienetai.)')", + "polygon-label-function": "Daugiakampio etiketės funkcija", + "polygon-tooltip": "Daugiakampio patarimas", + "show-polygon-tooltip": "Rodyti daugiakampio patarimą", + "auto-close-polygon-tooltips": "Automatiškai uždaryti daugiakampio patarimus", + "use-polygon-tooltip-function": "Naudoti daugiakampio patarimo funkciją", + "polygon-tooltip-pattern": "Patarimas (pvz.: 'Tekstas ${keyName} vienetai.' arba Nuorodos tekstas)", + "polygon-tooltip-function": "Daugiakampio patarimo funkcija", + "polygon-color": "Daugiakampio spalva", + "polygon-opacity": "Daugiakampio permatomumas", + "use-polygon-color-function": "Naudoti daugiakampio spalvos funkciją", + "polygon-color-function": "Daugiakampio spalvos funkcija", + "polygon-stroke": "Daugiakampio brūkšnys", + "stroke-color": "Brūkšnio spalva", + "stroke-opacity": "Brūkšnio permatomumas", + "stroke-weight": "Brūkšnio storis", + "use-polygon-stroke-color-function": "Naudoti daugiakampio brūkšnio spalvos funkciją", + "polygon-stroke-color-function": "Daugiakampio brūkšnio spalvos funkcija", + "circle-settings": "Apskritimo nustatymai", + "show-circle": "Rodyti apskritimą", + "circle-key-name": "Apskritimo rakto pavadinimas", + "enable-circle-edit": "Įjungti apskritimo redagavimą", + "circle-label": "Apskritimo etiketė", + "show-circle-label": "Rodyti apskritimo etiketę", + "use-circle-label-function": "Naudoti apskritimo etiketės funkciją", + "circle-label-pattern": "Apskritimo etiketė (pavyzdžiai: '${entityName}', '${entityName}: (Tekstas ${keyName} vienetai.)')", + "circle-label-function": "Apskritimo etiketės funkcija", + "circle-tooltip": "Apskritimo patarimas", + "show-circle-tooltip": "Rodyti apskritimo patarimą", + "auto-close-circle-tooltips": "Automatiškai uždaryti apskritimo patarimus", + "use-circle-tooltip-function": "Naudoti apskritimo patarimo funkciją", + "circle-tooltip-pattern": "Patarimas (pvz.: 'Tekstas ${keyName} vienetai.' arba Nuorodos tekstas)", + "circle-tooltip-function": "Apskritimo patarimo funkcija", + "circle-fill-color": "Apskritimo užpildo spalva", + "circle-fill-color-opacity": "Apskritimo užpildo permatomumas", + "use-circle-fill-color-function": "Naudoti apskritimo užpildo spalvos funkciją", + "circle-fill-color-function": "Apskritimo užpildo spalvos funkcija", + "circle-stroke": "Apskritimo brūkšnys", + "use-circle-stroke-color-function": "Naudoti apskritimo brūkšnio spalvos funkciją", + "circle-stroke-color-function": "Apskritimo brūkšnio spalvos funkcija", + "markers-clustering-settings": "Žymeklių grupavimo nustatymai", + "use-map-markers-clustering": "Naudoti žymeklių grupavimą", + "zoom-on-cluster-click": "Priartinti paspaudus ant grupės", + "max-cluster-zoom": "Didžiausias priartinimo lygis, kuriame žymeklis gali priklausyti grupei (0–18)", + "max-cluster-radius-pixels": "Didžiausias grupės spindulys (pikseliais)", + "cluster-zoom-animation": "Rodyti animaciją žymekliams priartinant", + "show-markers-bounds-on-cluster-mouse-over": "Rodyti žymeklių ribas užvedus pelę ant grupės", + "spiderfy-max-zoom-level": "Išskleisti (spiderfy) didžiausiame priartinimo lygyje, kad matytųsi visi žymekliai", + "load-optimization": "Įkėlimo optimizavimas", + "cluster-chunked-loading": "Naudoti dalinį žymeklių įkėlimą, kad puslapis neužstrigtų", + "cluster-markers-lazy-load": "Naudoti „lazy load“ žymeklių įkėlimui", + "editor-settings": "Redaktoriaus nustatymai", + "enable-snapping": "Įjungti pritraukimą prie kitų viršūnių tikslumui", + "init-draggable-mode": "Paleisti žemėlapį vilkiojimo režimu", + "hide-all-edit-buttons": "Slėpti visus redagavimo valdiklius", + "hide-draw-buttons": "Slėpti braižymo mygtukus", + "hide-edit-buttons": "Slėpti redagavimo mygtukus", + "hide-remove-button": "Slėpti šalinimo mygtuką", + "route-map-settings": "Maršruto žemėlapio nustatymai", + "trip-animation-settings": "Kelionės animacijos nustatymai", + "normalization-step": "Normalizavimo žingsnis (ms)", + "tooltip-background-color": "Patarimo fono spalva", + "tooltip-font-color": "Patarimo teksto spalva", + "tooltip-opacity": "Patarimo permatomumas (0–1)", + "auto-close-tooltip": "Automatiškai uždaryti patarimą", + "rotation-angle": "Papildomas žymeklio pasukimo kampas (laipsniais)", + "path-settings": "Kelio nustatymai", + "path-color": "Kelio spalva", + "use-path-color-function": "Naudoti kelio spalvos funkciją", + "path-color-function": "Kelio spalvos funkcija", + "path-decorator": "Kelio dekoratorius", + "use-path-decorator": "Naudoti kelio dekoratorių", + "decorator-symbol": "Dekoratoriaus simbolis", + "decorator-symbol-arrow-head": "Rodyklė", + "decorator-symbol-dash": "Brūkšnys", + "decorator-symbol-size": "Dekoratoriaus simbolio dydis (px)", + "use-path-decorator-custom-color": "Naudoti pasirinktinę dekoratoriaus spalvą", + "decorator-custom-color": "Dekoratoriaus pasirinktinė spalva", + "decorator-offset": "Dekoratoriaus poslinkis", + "end-decorator-offset": "Dekoratoriaus pabaigos poslinkis", + "decorator-repeat": "Dekoratoriaus kartojimas", + "points-settings": "Taškų nustatymai", + "show-points": "Rodyti taškus", + "point-color": "Taško spalva", + "point-size": "Taško dydis (px)", + "use-point-color-function": "Naudoti taško spalvos funkciją", + "point-color-function": "Taško spalvos funkcija", + "use-point-as-anchor": "Naudoti tašką kaip inkarą", + "point-as-anchor-function": "Taško kaip inkaro funkcija", + "independent-point-tooltip": "Nepriklausomas taško patarimas", + "clustering-markers": "Žymeklių grupavimas", + "use-icon-create-function": "Naudoti žymeklių spalvos funkciją", + "marker-color-function": "Žymeklio spalvos funkcija" }, - "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.", - "gateway": "Gateway", - "create-new-gateway": "Create a new gateway", - "create-new-gateway-text": "Are you sure you want create a new gateway with name: '{{gatewayName}}'?", - "no-gateway-found": "No gateway found.", - "no-gateway-matching": " '{{item}}' not found." - }, - "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" + "markdown": { + "use-markdown-text-function": "Naudoti markdown/HTML reikšmės funkciją", + "markdown-text-function": "Markdown/HTML reikšmės funkcija", + "markdown-text-pattern": "Markdown/HTML šablonas (markdown arba HTML su kintamaisiais, pvz.: '${entityName} arba ${keyName} - kažkoks tekstas.')", + "apply-default-markdown-style": "Taikyti numatytąjį markdown stilių", + "markdown-css": "Markdown/HTML CSS" }, - "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ų." - } + "simple-card": { + "label": "Etiketė", + "label-position": "Etiketės padėtis", + "label-position-left": "Kairėje", + "label-position-top": "Viršuje" }, - "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" + "single-switch": { + "behavior": "Elgsena", + "layout": "Išdėstymas", + "layout-right": "Dešinėje", + "layout-left": "Kairėje", + "layout-centered": "Centruota", + "auto-scale": "Automatinis mastelis", + "label": "Etiketė", + "icon": "Piktograma", + "switch-color": "Jungiklio spalva", + "on": "Įjungta", + "off": "Išjungta", + "disabled": "Išjungta būsena", + "tumbler-color": "Perjungiklio spalva", + "on-label": "Įjungta etiketė", + "off-label": "Išjungta etiketė", + "switch": "Jungiklis" }, - "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)" - } + "slider": { + "behavior": "Elgsena", + "initial-value": "Pradinė reikšmė", + "initial-value-hint": "Veiksmas, skirtas gauti pradinę slankiklio reikšmę.", + "on-value-change": "Keičiantis reikšmei", + "on-value-change-hint": "Veiksmas, paleidžiamas, kai pasikeičia slankiklio reikšmė.", + "layout": "Išdėstymas", + "layout-default": "Numatytasis", + "layout-extended": "Išplėstinis", + "layout-simplified": "Supaprastintas", + "auto-scale": "Automatinis mastelis", + "icon": "Piktograma", + "value": "Reikšmė", + "range": "Intervalas", + "min": "min", + "max": "maks", + "range-ticks": "Intervalo žymės", + "tick-marks": "Padalos", + "colors": "Spalvos", + "main": "Pagrindinė", + "background": "Fonas", + "left-icon": "Kairioji piktograma", + "right-icon": "Dešinioji piktograma", + "slider": "Slankiklis" }, - "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" + "value-card": { + "layout": "Išdėstymas", + "layout-square": "Kvadratinis", + "layout-vertical": "Vertikalus", + "layout-centered": "Centruotas", + "layout-simplified": "Supaprastintas", + "layout-horizontal": "Horizontalus", + "layout-horizontal-reversed": "Apverstas horizontalus", + "label": "Etiketė", + "icon": "Piktograma", + "value": "Reikšmė", + "date": "Data", + "value-card-style": "Reikšmės kortelės stilius", + "auto-scale": "Automatinis mastelis" + }, + "label-card": { + "auto-scale": "Automatinis mastelis", + "label": "Etiketė", + "icon": "Piktograma", + "label-card-style": "Etiketės kortelės stilius" + }, + "label-value-card": { + "value": "Reikšmė", + "label-value-card-style": "Etiketės ir reikšmės kortelės stilius" + }, + "liquid-level-card": { + "layout-simple": "Paprastas", + "layout-percentage": "Procentinis", + "layout-absolute": "Absoliutus", + "layout": "Išdėstymas", + "background-overlay": "Reikšmės fono perdanga", + "total-volume": "Bendras tūris", + "total-volume-units": "Bendro tūrio vienetai", + "tank": "Talpa", + "shape": "Forma", + "datasource-units": "Šaltinio vienetai", + "widget-units": "Valdiklio vienetai", + "decimals": "Dešimtainės", + "liquid": "Skystis", + "liquid-color": "Skysčio spalva", + "value": "Reikšmė", + "value-font": "Reikšmės šriftas", + "level": "Lygis", + "last-update": "Paskutinis atnaujinimas", + "shape-by-attribute": "Nustatyti talpos formą pagal atributo pavadinimą", + "tooltip-background": "Fono spalva", + "background-blur": "Fono suliejimas", + "tank-color": "Talpos spalva", + "static": "Statinis", + "see-examples": "Peržiūrėti pavyzdžius", + "attribute": "Atributas", + "shape-type": "Tipas", + "v-oval": "Vertikali ovalo forma", + "v-cylinder": "Vertikalus cilindras", + "v-capsule": "Vertikali kapsulė", + "rectangle": "Stačiakampis", + "h-oval": "Horizontalus ovalas", + "h-ellipse": "Horizontali elipsė", + "h-dish-ends": "Horizontalūs išgaubti galai", + "h-cylinder": "Horizontalus cilindras", + "h-capsule": "Horizontali kapsulė", + "h-elliptical_2_1": "Horizontalus 2:1 elipsinis", + "icon": "Kortelės piktograma", + "title": "Kortelės pavadinimas", + "units": "Vienetai", + "color-and-font": "Spalva ir šriftas", + "shape-attribute-name": "Atributo pavadinimas", + "total-volume-required": "Bendras tūris yra privalomas.", + "attribute-name-required": "Atributo pavadinimas yra privalomas.", + "attribute-key-not-set": "Atributo '{{attributeName}}' raktas nenustatytas", + "attribute-key-invalid": "Atributo '{{attributeName}}' raktas neteisingas" + }, + "aggregated-value-card": { + "subtitle": "Paantraštė", + "chart": "Diagrama", + "values": "Reikšmės", + "value-appearance": "Reikšmės išvaizda", + "position": "Padėtis", + "position-center": "Centre", + "position-right-top": "Dešinėje viršuje", + "position-right-bottom": "Dešinėje apačioje", + "position-left-top": "Kairėje viršuje", + "position-left-bottom": "Kairėje apačioje", + "font": "Šriftas", + "color": "Spalva", + "arrow": "Rodyklė", + "display-up-down-arrow": "Rodyti aukštyn/žemyn rodyklę", + "add-value": "Pridėti reikšmę", + "remove-value": "Pašalinti reikšmę", + "no-values": "Reikšmės nesukonfigūruotos", + "aggregation": "Agregavimas", + "aggregated-value-card-style": "Agreguotų reikšmių kortelės stilius", + "auto-scale": "Automatinis mastelis" + }, + "value-chart-card": { + "layout": "Išdėstymas", + "layout-left": "Kairėje", + "layout-right": "Dešinėje", + "auto-scale": "Automatinis mastelis", + "icon": "Piktograma", + "value": "Reikšmė", + "chart": "Diagrama", + "value-chart-card-style": "Reikšmės ir diagramos kortelės stilius" + }, + "progress-bar": { + "layout": "Išdėstymas", + "layout-default": "Numatytasis", + "layout-simplified": "Supaprastintas", + "auto-scale": "Automatinis mastelis", + "icon": "Piktograma", + "value": "Reikšmė", + "range": "Intervalas", + "min": "min", + "max": "maks", + "range-ticks": "Intervalo žymės", + "bar": "Juosta", + "bar-color": "Juostos spalva", + "bar-background": "Juostos fonas", + "progress-bar-card-style": "Progreso juostos kortelės stilius" }, "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" - } + "max-notification-display": "Maksimalus rodomų pranešimų skaičius", + "counter": "Skaitiklis", + "counter-hint": "Skaitiklis bus rodomas, jei įjungtas „Valdiklio pavadinimas“", + "icon": "Piktograma", + "counter-value": "Reikšmė", + "counter-color": "Spalva", + "notification-button": "Pranešimų mygtukai", + "button-view-all": "Peržiūrėti visus", + "button-filter": "Filtruoti", + "type-filter": "Tipų filtras", + "button-mark-read": "Pažymėti visus kaip skaitytus", + "notification-types": "Pranešimų tipai", + "notification-type": "Pranešimo tipas", + "search-type": "Paieškos tipas", + "any-type": "Bet koks tipas" }, - "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} }" - } + "alarm-count": { + "alarm-count-card-style": "Signalizacijų skaičiaus kortelės stilius" }, - "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" - } + "entity-count": { + "entity-count-card-style": "Objektų skaičiaus kortelės stilius" }, - "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", - "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", - "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ė" - } + "count": { + "layout": "Išdėstymas", + "layout-column": "Stulpelis", + "layout-row": "Eilutė", + "label": "Etiketė", + "icon": "Piktograma", + "icon-background": "Piktogramos fonas", + "value": "Reikšmė", + "chevron": "Rodyklė", + "auto-scale": "Automatinis mastelis" }, - "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" - } - } + "table": { + "common-table-settings": "Bendrieji lentelės nustatymai", + "enable-search": "Įjungti paiešką", + "enable-sticky-header": "Visada rodyti antraštę", + "enable-sticky-action": "Visada rodyti veiksmų stulpelį", + "hidden-cell-button-display-mode": "Paslėpto mygtuko veiksmų rodymo režimas", + "show-empty-space-hidden-action": "Rodyti tuščią vietą vietoje paslėpto langelio mygtuko veiksmo", + "dont-reserve-space-hidden-action": "Nerezervuoti vietos paslėptiems veiksmų mygtukams", + "display-timestamp": "Rodyti laiko žymos stulpelį", + "display-pagination": "Rodyti puslapiavimą", + "default-page-size": "Numatytasis puslapio dydis", + "page-step-settings": "Puslapio žingsnių nustatymai", + "page-step-count": "Žingsnių skaičius", + "page-step-increment": "Žingsnio didinimas", + "page-step-count-format-message": "Turi būti sveikasis skaičius nuo 1 iki 100.", + "page-step-increment-format-message": "Turi būti sveikasis skaičius, ne mažesnis kaip 1.", + "use-entity-label-tab-name": "Naudoti objekto etiketę skirtuko pavadinime", + "hide-empty-lines": "Slėpti tuščias eilutes", + "row-style": "Eilutės stilius", + "use-row-style-function": "Naudoti eilutės stiliaus funkciją", + "row-style-function": "Eilutės stiliaus funkcija", + "cell-style": "Langelio stilius", + "use-cell-style-function": "Naudoti langelio stiliaus funkciją", + "cell-style-function": "Langelio stiliaus funkcija", + "cell-content": "Langelio turinys", + "use-cell-content-function": "Naudoti langelio turinio funkciją", + "cell-content-function": "Langelio turinio funkcija", + "show-latest-data-column": "Rodyti naujausių duomenų stulpelį", + "latest-data-column-order": "Naujausių duomenų stulpelio tvarka", + "entities-table-title": "Objektų lentelės pavadinimas", + "enable-select-column-display": "Įjungti pasirinktų stulpelių rodymą", + "display-entity-name": "Rodyti objekto pavadinimo stulpelį", + "entity-name-column-title": "Objekto pavadinimo stulpelio pavadinimas", + "display-entity-label": "Rodyti objekto etiketės stulpelį", + "entity-label-column-title": "Objekto etiketės stulpelio pavadinimas", + "display-entity-type": "Rodyti objekto tipo stulpelį", + "default-sort-order": "Numatytoji rūšiavimo tvarka", + "custom-title": "Tinkintas antraštės pavadinimas", + "column-width": "Stulpelio plotis (px arba %)", + "default-column-visibility": "Numatytasis stulpelio matomumas", + "column-visibility-visible": "Matomas", + "column-visibility-hidden": "Paslėptas", + "column-visibility-hidden-mobile": "Paslėptas mobiliuoju režimu", + "column-selection-to-display": "Stulpelių pasirinkimas rodymui", + "column-selection-to-display-enabled": "Įjungta", + "column-selection-to-display-disabled": "Išjungta", + "alarms-table-title": "Pavojaus signalų lentelės pavadinimas", + "enable-alarms-selection": "Įjungti signalų pasirinkimą", + "enable-alarms-search": "Įjungti signalų paiešką", + "enable-alarm-filter": "Įjungti signalų filtrą", + "display-alarm-details": "Rodyti signalų detales", + "allow-alarms-ack": "Leisti patvirtinti signalus", + "allow-alarms-clear": "Leisti išvalyti signalus", + "display-alarm-activity": "Rodyti signalų veiklą", + "allow-alarms-assign": "Leisti priskirti signalus", + "columns": "Stulpeliai", + "column-settings": "Stulpelių nustatymai", + "remove-column": "Pašalinti stulpelį", + "add-column": "Pridėti stulpelį", + "no-columns": "Stulpeliai nesukonfigūruoti", + "columns-to-display": "Stulpeliai, kuriuos rodyti", + "table-header": "Lentelės antraštė", + "header-buttons": "Antraštės mygtukai", + "table-buttons": "Lentelės mygtukai", + "pagination": "Puslapiavimas", + "rows": "Eilutės", + "timeseries-column-error": "Turi būti nurodytas bent vienas laiko sekos stulpelis", + "alarm-column-error": "Turi būti nurodytas bent vienas signalų stulpelis", + "table-tabs": "Lentelės skirtukai", + "show-cell-actions-menu-mobile": "Rodyti langelio veiksmų meniu mobiliajame režime", + "disable-sorting": "Išjungti rūšiavimą" }, - "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ą", + "latest-chart": { + "total": "Bendra suma", + "auto-scale": "Automatinis mastelis", + "clockwise-layout": "Išdėstymas pagal laikrodžio rodyklę", + "sort-series": "Rūšiuoti serijas pagal etiketę", + "tooltip-value-type-absolute": "Absoliuti reikšmė", + "tooltip-value-type-percentage": "Procentinė reikšmė" + }, + "pie-chart": { + "pie-chart-appearance": "Skritulinės diagramos išvaizda", + "label": "Etiketė", + "border": "Kraštinė", + "radius": "Spindulys", + "pie-chart-card-style": "Skritulinės diagramos kortelės stilius" + }, + "radar-chart": { + "radar-appearance": "Radaro diagramos išvaizda", + "shape": "Forma", + "shape-polygon": "Daugiakampis", + "shape-circle": "Apskritimas", + "color": "Spalva", + "line": "Linija", + "points": "Taškai", + "points-label": "Taškų etiketė", + "radar-axis": "Radaro ašis", + "axis-label": "Ašies etiketė", + "ticks-label": "Padalų etiketė", + "radar-chart-style": "Radaro diagramos stilius", + "max-axes-scaling": "Maksimalus ašių mastelis", + "max-axes-scaling-hint": "Pasirinkite, ar kiekviena radaro ašis turi savo maksimalų dydį (Atskiras), ar dalijasi didžiausia reikšme tarp visų ašių pagal valdiklio duomenis (Bendras).", + "separate": "Atskiras", + "common": "Bendras" + }, + "time-series-chart": { + "chart": "Diagrama", + "chart-style": "Diagramos stilius", + "data-zoom": "Duomenų mastelio keitimas", + "stack-mode": "Sluoksniavimo režimas", + "stack-mode-hint": "Sluoksniuoja serijas diagramoje. Serijos su tomis pačiomis vienetais bus dedamos viena ant kitos.", + "axes": "Ašys", + "y-axes": "Y ašys", + "line-type": "Linijos tipas", + "line-width": "Linijos storis", + "type-line": "Linija", + "type-bar": "Stulpelis", + "type-point": "Taškas", + "no-aggregation-bar-width-strategy": "Stulpelių pločio strategija neagreguotiems duomenims", + "no-aggregation-bar-width-strategy-group": "Grupuoti", + "no-aggregation-bar-width-strategy-separate": "Atskiri", + "bar-group-width": "Stulpelių grupės plotis", + "bar-width": "Stulpelio plotis", + "bar-width-relative": "Procentinė laiko lango dalis", + "bar-width-absolute": "Absoliutus (ms)", + "comparison": { + "comparison": "Palyginimas", + "comparison-hint": "Palyginimas veikia tik su istorinių duomenų!", + "show": "Rodyti", + "settings": "Palyginimo nustatymai", + "show-values-for-comparison": "Rodyti istorinius duomenis palyginimui", + "comparison-values-label": "Palyginimo rakto etiketė", + "comparison-values-label-auto": "Automatinė", + "comparison-data-color": "Palyginimo duomenų spalva" + }, + "threshold": { + "thresholds": "Slenksčiai", + "source": "Šaltinis", + "key-value": "Raktas / Reikšmė", + "no-thresholds": "Slenksčiai nesukonfigūruoti", + "add-threshold": "Pridėti slenkstį", + "type-constant": "Pastovus", + "type-latest-key": "Raktas", + "type-entity": "Objektas", + "threshold-settings": "Slenksčio nustatymai", + "remove-threshold": "Pašalinti slenkstį", + "threshold-value-required": "Reikalinga slenksčio reikšmė.", + "key-required": "Reikalingas raktas.", + "entity-key-required": "Reikalingas objekto raktas.", + "line-appearance": "Linijos išvaizda", + "line-color": "Linijos spalva", + "start-symbol": "Pradžios simbolis", + "end-symbol": "Pabaigos simbolis", + "symbol-size": "Dydis", + "label": "Etiketė", + "label-position-start": "Pradžia", + "label-position-middle": "Vidurys", + "label-position-end": "Pabaiga", + "label-position-inside-start": "Viduje pradžioje", + "label-position-inside-start-top": "Viduje viršuje pradžioje", + "label-position-inside-start-bottom": "Viduje apačioje pradžioje", + "label-position-inside-middle": "Viduje per vidurį", + "label-position-inside-middle-top": "Viduje per vidurį viršuje", + "label-position-inside-middle-bottom": "Viduje per vidurį apačioje", + "label-position-inside-end": "Viduje pabaigoje", + "label-position-inside-end-top": "Viduje pabaigoje viršuje", + "label-position-inside-end-bottom": "Viduje pabaigoje apačioje", + "label-background": "Etiketės fonas" + }, + "state": { + "states": "Būsenos", + "label": "Etiketė", + "ticks-value": "Padalų reikšmė", + "source": "Šaltinis", + "value-range": "Reikšmė / Diapazonas", + "no-states": "Būsenos nesukonfigūruotos", + "add-state": "Pridėti būseną", + "type-constant": "Pastovi", + "type-range": "Diapazonas", "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", + "to": "Iki", + "remove-state": "Pašalinti būseną" + }, + "grid": { + "grid": "Tinklelis", + "background-color": "Fono spalva", + "border": "Rėmelis" + }, + "axis": { + "axes": "Ašys", + "x-axis": "X ašis", + "y-axis": "Y ašis", + "y-axis-settings": "Y ašies nustatymai", + "comparison-x-axis-settings": "Palyginimo X ašies nustatymai", + "remove-y-axis": "Pašalinti Y ašį", + "id": "Id", + "label": "Etiketė", + "position": "Pozicija", + "position-left": "Kairėje", + "position-right": "Dešinėje", + "position-top": "Viršuje", + "position-bottom": "Apačioje", + "tick-labels": "Padalų etiketės", + "ticks-formatter-function": "Padalų formatavimo funkcija", + "ticks-generator-function": "Padalų generavimo funkcija", + "show-ticks": "Rodyti padalas", + "show-line": "Rodyti liniją", + "show-split-lines": "Rodyti padalų linijas", + "show-split-lines-x-axis-hint": "Jei įjungta, bus rodomos vertikalios diagramos linijos.", + "show-split-lines-y-axis-hint": "Jei įjungta, bus rodomos horizontalios diagramos linijos.", + "ticks-interval": "Padalų intervalas", + "ticks-interval-hint": "Priverstinai nustato ašies segmentacijos intervalą.", + "split-number": "Padalų skaičius", + "split-number-hint": "Padalų, į kurias suskirstyta ašis, skaičius.", + "min": "Min.", + "max": "Maks.", + "show": "Rodyti", + "add-y-axis": "Pridėti Y ašį" + }, + "series": { + "legend-settings": "Legenda nustatymai", + "show-in-legend": "Rodyti legendoje", + "show-in-legend-hint": "Rodyti serijos pavadinimą ir duomenis legendoje.", + "hidden-by-default": "Numatytai paslėpta", + "hidden-by-default-hint": "Padaryti seriją paslėptą legendoje pagal numatytuosius nustatymus.", + "series-type": "Serijos tipas", "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" + "type-line": "Linija", + "type-bar": "Stulpelis", + "line": { + "line": "Linija", + "show-line": "Rodyti liniją", + "step-line": "Pakopinė linija", + "step-type-start": "Pradžia", + "step-type-middle": "Vidurys", + "step-type-end": "Pabaiga", + "smooth-line": "Glotni linija" + }, + "point": { + "points": "Taškai", + "show-points": "Rodyti taškus", + "point-label": "Taško etiketė", + "point-label-hint": "Rodyti etiketę su reikšme virš serijos taško.", + "point-label-background": "Taško etiketės fonas", + "point-shape": "Taško forma", + "point-size": "Taško dydis" } + } }, - "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-edges": "Edges maximum number", - "maximum-edges-required": "Edges maximum number is required.", - "maximum-edges-range": "Edges 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", - "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" - } + "wind-speed-direction": { + "layout": "Išdėstymas", + "layout-default": "Numatytasis", + "layout-advanced": "Išplėstinis", + "layout-simplified": "Supaprastintas", + "values": "Reikšmės", + "wind-direction": "Vėjo kryptis", + "center-value": "Centrinė reikšmė", + "icon": "Piktograma", + "arrow": "Rodyklė", + "ticks": "Padalos", + "labels-type": "Etikečių tipas", + "directional-names": "Krypties pavadinimai", + "degrees": "Laipsniai", + "major-ticks": "Pagrindinės padalos", + "minor-ticks": "Smulkios padalos", + "wind-speed-direction-card-style": "Vėjo greičio ir krypties kortelės stilius", + "ticks-color": "Padalų spalva", + "ticks-labels-type": "Padalų etikečių tipas", + "arrow-color": "Rodyklės spalva" }, - "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" - } + "value-source": { + "value-source": "Reikšmės šaltinis", + "predefined-value": "Iš anksto nustatyta reikšmė", + "entity-attribute": "Objekto atributas", + "value": "Reikšmė", + "value-required": "Reikšmė yra privaloma.", + "key-required": "Raktas yra privalomas.", + "entity-key-required": "Objekto raktas yra privalomas.", + "source-entity-alias": "Šaltinio objekto pseudonimas", + "source-entity-attribute": "Šaltinio objekto atributas", + "type-constant": "Pastovus", + "type-latest-key": "Raktas", + "type-entity": "Objektas" }, - "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", - "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." + "rpc-state": { + "initial-state": "Pradinė būsena", + "initial-state-hint": "Veiksmas, skirtas gauti pradinę komponento būseną (Įjungta / Išjungta).", + "disabled-state": "Išjungta būsena", + "disabled-state-hint": "Sąlyga, pagal kurią komponentas yra išjungiamas.", + "turn-on": "Įjungti", + "turn-on-hint": "Veiksmas, paleidžiamas, kai jungiklis perjungiamas į 'Įjungta'.", + "turn-off": "Išjungti", + "turn-off-hint": "Veiksmas, paleidžiamas, kai jungiklis perjungiamas į 'Išjungta'.", + "on": "Įjungta", + "off": "Išjungta", + "disabled": "Išjungta" }, - "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" - } + "value-action": { + "do-nothing": "Nedaryti nieko", + "execute-rpc": "Vykdyti RPC komandą", + "get-attribute": "Gauti atributą", + "set-attribute": "Nustatyti atributą", + "get-time-series": "Gauti laiko eilutę", + "get-alarm-status": "Gauti aliarmo būseną", + "get-dashboard-state": "Gauti skydelio būsenos ID", + "get-dashboard-state-object": "Gauti skydelio būsenos objektą", + "add-time-series": "Pridėti laiko eilutę", + "execute-rpc-text": "Vykdyti RPC metodą '{{methodName}}'", + "get-time-series-text": "Naudoti laiko eilutę '{{key}}'", + "get-attribute-text": "Naudoti atributą '{{key}}'", + "get-alarm-status-text": "Naudoti aliarmo būseną", + "get-dashboard-state-text": "Naudoti skydelio būseną", + "get-dashboard-state-object-text": "Naudoti skydelio būsenos objektą", + "when-dashboard-state-is-text": "Kai skydelio būsena yra '{{state}}'", + "when-dashboard-state-function-is-text": "Kai f(skydelio būsena) = '{{state}}'", + "when-dashboard-state-object-function-is-text": "Kai f(skydelio būsenos objektas) = '{{state}}'", + "set-attribute-to-value-text": "Nustatyti '{{key}}' atributo reikšmę į: {{value}}", + "add-time-series-value-text": "Pridėti '{{key}}' laiko eilutės reikšmę: {{value}}", + "set-attribute-text": "Nustatyti '{{key}}' atributą", + "add-time-series-text": "Pridėti '{{key}}' laiko eilutę", + "action": "Veiksmas", + "value": "Reikšmė", + "init-value-hint": "Reikšmė, kuri bus naudojama iki kol įrenginys atsiųs duomenis.", + "method": "Metodas", + "method-name-required": "Metodo pavadinimas yra privalomas.", + "request-timeout-ms": "RPC užklausos laiko limitas (ms)", + "request-timeout-required": "Reikalingas užklausos laiko limitas.", + "min-request-timeout-error": "Užklausos laiko limitas turi būti ne mažesnis nei 5000 ms (5 sekundės).", + "request-persistent": "Išliekanti RPC užklausa", + "persistent-polling-interval": "Išliekantis apklausos intervalas (ms)", + "persistent-polling-interval-hint": "Apklausos intervalas (ms) išliekantiems RPC komandų atsakymams gauti", + "persistent-polling-interval-required": "Išliekantis apklausos intervalas yra privalomas.", + "min-persistent-polling-interval-error": "Apklausos intervalas turi būti ne mažesnis nei 1000 ms (1 sekundė).", + "attribute-scope": "Atributo taikymo sritis", + "attribute-key": "Atributo raktas", + "attribute-key-required": "Atributo raktas yra privalomas.", + "time-series-key": "Laiko eilutės raktas", + "time-series-key-required": "Laiko eilutės raktas yra privalomas.", + "action-result-converter": "Veiksmo rezultatų konverteris", + "converter-none": "Nėra", + "converter-function": "Funkcija", + "converter-constant": "Pastovi reikšmė", + "converter-value": "Reikšmė", + "parse-value-function": "Reikšmės apdorojimo funkcija", + "state-when-result-is": "'{{state}}' kai rezultatas yra", + "parameters": "Parametrai", + "convert-value-function": "Reikšmės konvertavimo funkcija", + "error": { + "target-entity-is-not-set": "Tikslinis objektas nenustatytas!", + "failed-to-perform-action": "Nepavyko atlikti veiksmo {{ actionLabel }}.", + "invalid-attribute-scope": "{{scope}} atributų taikymo sritis nepalaikoma {{entityType}} objekte." + } }, - "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..." + "widget-font": { + "font-settings": "Šrifto nustatymai", + "font-family": "Šrifto šeima", + "size": "Dydis", + "relative-font-size": "Santykinis šrifto dydis (procentais)", + "font-style": "Stilius", + "font-style-normal": "Normalus", + "font-style-italic": "Kursyvas", + "font-style-oblique": "Pasviręs", + "font-weight": "Storis", + "font-weight-normal": "Normalus", + "font-weight-bold": "Paryškintas", + "font-weight-bolder": "Labiau paryškintas", + "font-weight-lighter": "Plonesnis", + "color": "Spalva", + "shadow-color": "Šešėlio spalva", + "preview": "Peržiūra", + "line-height": "Eilučių aukštis", + "auto": "Automatinis" }, - "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" - } + "home": { + "no-data-available": "Nėra prieinamų duomenų" }, - "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", - "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" + "system-info": { + "cpu": "CPU", + "ram": "RAM", + "disk": "Diskas", + "cpu-warning-text": "Didelis procesoriaus apkrovimas. Siekiant išvengti sistemos gedimo, optimizuokite našumą.", + "cpu-critical-text": "Kritiškai didelis procesoriaus apkrovimas. Nedelsiant optimizuokite sistemos našumą.", + "ram-warning-text": "Mažas RAM rezervas. Padidinkite RAM arba optimizuokite sistemos veikimą.", + "ram-critical-text": "Kritiškai mažas RAM rezervas. Būtina optimizacija arba RAM padidinimas.", + "disk-warning-text": "Mažai vietos diske. Išvalykite arba išplėskite disko talpą.", + "disk-critical-text": "Kritiškai mažai vietos diske. Skubiai atlaisvinkite arba išplėskite talpą." + }, + "cluster-info": { + "service-id": "Paslaugos ID", + "service-type": "Paslaugos tipas", + "no-data": "Nėra duomenų" + }, + "transport-messages": { + "title": "Transporto pranešimai", + "info": "Visi pranešimai, gauti iš įrenginių" + }, + "activity": { + "title": "Aktyvumas" + }, + "documentation": { + "title": "Dokumentacija", + "add-link": "Pridėti nuorodą", + "add-link-title": "Pridėti dokumentacijos nuorodą", + "name": "Pavadinimas", + "name-required": "Pavadinimas yra privalomas.", + "link": "Nuoroda", + "link-required": "Nuoroda yra privaloma.", + "columns": "Stulpeliai" + }, + "quick-links": { + "title": "Greitosios nuorodos", + "add-link": "Pridėti nuorodą", + "add-link-title": "Pridėti greitąją nuorodą", + "quick-link": "Greitoji nuoroda", + "quick-link-required": "Greitoji nuoroda yra privaloma.", + "no-links-matching": "Nuorodų, atitinkančių '{{name}}', nerasta.", + "columns": "Stulpeliai" + }, + "recent-dashboards": { + "title": "Valdymo skydeliai", + "last": "Paskutiniai peržiūrėti", + "starred": "Pažymėti žvaigždute", + "name": "Pavadinimas", + "last-viewed": "Paskutinį kartą peržiūrėtas", + "no-last-viewed-dashboards": "Dar nėra peržiūrėtų skydelių" + }, + "configured-features": { + "title": "Sukonfigūruotos funkcijos", + "info": "Funkcijų, kurioms reikia konfigūracijos, būsena", + "email-feature": "El. paštas", + "sms-feature": "SMS", + "slack-feature": "Slack", + "oauth2-feature": "OAuth 2", + "2fa-feature": "Dvigubas autentifikavimas (2FA)", + "feature-configured": "Funkcija sukonfigūruota.\nSpustelėkite, kad nustatytumėte", + "feature-not-configured": "Funkcija nesukonfigūruota.\nSpustelėkite, kad nustatytumėte" + }, + "version-info": { + "title": "Versija", + "contact-us": "Susisiekite su mumis", + "current-version": "Dabartinė versija", + "current": "Dabartinė", + "available-version": "Prieinama versija", + "available": "Prieinama", + "upgrade": "Atnaujinti", + "version-is-up-to-date": "Versija yra naujausia" + }, + "usage-info": { + "title": "Naudojimas", + "entities": "Objektai", + "api-calls": "API užklausos" + }, + "functions": { + "title": "Funkcijos", + "pe-feature-tooltip": "Galima tik ThingsBoard\nProfessional Edition versijoje", + "switch-to-pe": "Pereiti į PE versiją", + "alarms": "Aliarmai", + "dashboards": "Valdymo skydeliai", + "entities-and-relations": "Objektai ir ryšiai", + "profiles": "Profiliai", + "advanced-features": "Išplėstinės funkcijos", + "notification-center": "Pranešimų centras", + "api-usage": "API naudojimas", + "customers": "Klientai", + "customers-hierarchy": "Klientų hierarchija", + "roles-and-permissions": "Vaidmenys ir leidimai", + "groups": "Grupės", + "integrations": "Integracijos", + "solution-templates": "Sprendimų šablonai", + "scheduler": "Tvarkaraštis", + "white-labeling": "Prekinio ženklo pritaikymas (White-labeling)" + }, + "devices": { + "view-docs": "Peržiūrėti dokumentaciją", + "inactive": "Neaktyvūs", + "active": "Aktyvūs", + "total": "Iš viso" + }, + "alarms": { + "critical": "Kritiniai", + "assigned-to-me": "Priskirti man", + "total": "Iš viso" + }, + "getting-started": { + "get-started": "Pradėti", + "finish": "Baigti", + "done-welcome-title": "Sveiki atvykę!", + "done-welcome-text": "Puikiai susitvarkėte!", + "sys-admin": { + "step1": { + "title": "Sukurkite nuomininką ir jo administratorių", + "content": "

    Nuomininkas – tai fizinis ar juridinis asmuo, turintis arba valdantis įrenginius ir išteklius. Nuomininkas gali turėti kelis administratoriaus vartotojus, klientus, įrenginius ir išteklius.

    Nuomininko administratorius gali kurti ir valdyti įrenginius, išteklius, klientus bei valdymo skydelius nuomininko paskyroje.

    Sekite dokumentaciją, kaip tai atlikti:

    ", + "how-to-create-tenant": "Kaip sukurti nuomininką ir jo administratorių" }, - "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" + "step2": { + "title": "Sukonfigūruokite funkciją: Pašto serveris", + "content": "

    Pašto serverio konfigūracija būtina vartotojų aktyvavimui, slaptažodžio atkūrimui ir aliarmų pranešimų siuntimui.

    Sekite dokumentaciją, kaip tai atlikti:

    ", + "how-to-configure-mail-server": "Kaip sukonfigūruoti pašto serverį" }, - "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" + "step3": { + "title": "Sukonfigūruokite funkciją: SMS tiekėjas", + "content": "

    Sukonfigūruokite SMS tiekėjus, kad klientai gautų aliarmų pranešimus SMS žinutėmis.

    Sekite dokumentaciją, kaip tai atlikti:

    ", + "how-to-configure-sms-provider": "Kaip sukonfigūruoti SMS tiekėją" }, - "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" + "step4": { + "title": "Sukonfigūruokite funkciją: Prekinio ženklo pritaikymas", + "content": "

    Lengvai pritaikykite savo įmonės ar produkto logotipą ir spalvų schemą be programavimo ir paslaugos paleidimo iš naujo.

    Sekite dokumentaciją, kaip tai atlikti:

    " }, - "usage-info": { - "title": "Usage", - "entities": "Entities", - "api-calls": "API calls" + "step5": { + "title": "Sukonfigūruokite funkciją: Dvigubas autentifikavimas (2FA)", + "content": "

    Pagerinkite paskyrų saugumą naudodami dviejų faktorių autentifikavimą.

    Sekite dokumentaciją, kaip tai atlikti:

    " }, - "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" + "step6": { + "title": "Sukonfigūruokite funkciją: OAuth 2", + "content": "

    Supaprastinkite nuomininko ir kliento vartotojų prisijungimą naudodami vieningo prisijungimo (Single Sign-On) funkciją per OAuth 2.0.

    Sekite dokumentaciją, kaip tai atlikti:

    " + } + }, + "tenant-admin": { + "step1": { + "title": "Sukurti įrenginį", + "content": "

    Sukurkite savo pirmąjį įrenginį platformoje per naudotojo sąsają (UI). Sekite dokumentaciją, kaip tai atlikti:

    ", + "how-to-create-device": "Kaip sukurti įrenginį" }, - "devices": { - "view-docs": "View docs", - "inactive": "Inactive", - "active": "Active", - "total": "Total" + "step2": { + "title": "Prijungti įrenginį", + "content-before": "

    Norint prijungti įrenginį, reikia gauti jo prisijungimo duomenis. Šiame vadove rekomenduojame naudoti numatytuosius automatiškai sugeneruotus prisijungimo duomenis – prieigos raktą (Access Token).

    • Eikite į įrenginių lentelę
    • Spustelėkite įrenginio eilutę, kad atidarytumėte jo informaciją
    • Paspauskite mygtuką „Kopijuoti prieigos raktą“

    Naudokite paprastas HTTP komandas duomenų publikavimui. Nepamirškite pakeisti $ACCESS_TOKEN savo įrenginio prieigos raktu:

    ", + "ubuntu": { + "install-curl": "Įdiekite cURL Ubuntu sistemoje:" + }, + "macos": { + "install-curl": "Įdiekite cURL MacOS sistemoje:" + }, + "windows": { + "install-curl": "Nuo Windows 10 b17063 versijos cURL yra įdiegtas pagal nutylėjimą." + }, + "replace-access-token": "Pakeiskite $ACCESS_TOKEN savo įrenginio prieigos raktu:", + "content-after": "

    Taip pat galite naudoti kitus protokolus, tokius kaip MQTT, CoAP ir kt.

    Sekite dokumentaciją, kaip tai atlikti:

    ", + "how-to-connect-device": "Kaip prijungti įrenginį" }, - "alarms": { - "critical": "Critical", - "assigned-to-me": "Assigned to me", - "total": "Total" + "step3": { + "title": "Sukurti valdymo skydelį", + "content": "

    Sukurkite valdymo skydelį duomenų vizualizavimui iš objektų, tokių kaip įrenginiai ar ištekliai.

    Sekite dokumentaciją, kaip tai atlikti:

    ", + "how-to-create-dashboard": "Kaip sukurti valdymo skydelį" }, - "solution-templates": { - "title": "Solution templates", - "prototype-plan": "Prototype plan", - "startup-plan": "Startup plan" + "step4": { + "title": "Sukonfigūruoti aliarmo taisykles", + "alarm-rules": "Aliarmo taisyklės", + "content": "

    Sukurkime aliarmą, kuris suveiks, kai temperatūra pasieks 25°C. Sekite dokumentaciją, kaip tai atlikti:

    ", + "how-to-configure-alarm-rules": "Kaip sukonfigūruoti aliarmo taisykles" }, - "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!" + "step5": { + "title": "Sukurti aliarmą", + "content-before": "

    Norėdami suaktyvinti aliarmą, siųskite naują telemetrijos reikšmę, didesnę arba lygią 26°C.

    ", + "replace-access-token": "Pakeiskite $ACCESS_TOKEN savo įrenginio prieigos raktu:", + "content-after": "

    Sekite dokumentaciją, kaip tai atlikti:

    ", + "how-to-create-alarm": "Kaip sukurti aliarmą" }, - "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" + "step6": { + "title": "Sukurti klientą ir pasidalyti valdymo skydeliu", + "content": "

    Sukūrę galutinio naudotojo valdymo skydelius, kliento vartotojas matys tik savo įrenginius, o kitų klientų duomenys bus paslėpti.

    Sekite dokumentaciją, kaip tai atlikti:

    " } - }, - "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š" + } + } + }, + "icon": { + "icon": "Piktograma", + "icons": "Piktogramos", + "select-icon": "Pasirinkti piktogramą", + "material-icons": "Material piktogramos", + "show-all": "Rodyti visas piktogramas", + "search-icon": "Ieškoti piktogramos", + "no-icons-found": "Nerasta piktogramų pagal '{{iconSearch}}'" + }, + "phone-input": { + "phone-input-label": "Telefono numeris", + "phone-input-required": "Telefono numeris yra privalomas", + "phone-input-validation": "Telefono numeris yra neteisingas arba neįmanomas", + "phone-input-pattern": "Neteisingas telefono numeris. Formatą turi atitikti E.164, pvz. {{phoneNumber}}", + "phone-input-hint": "Telefono numeris tarptautiniu formatu, pvz. +37060123456" + }, + "custom": { + "widget-action": { + "action-cell-button": "Veiksmo langelio mygtukas", + "row-click": "Paspaudus eilutę", + "cell-click": "Paspaudus langelį", + "polygon-click": "Paspaudus daugiakampį", + "marker-click": "Paspaudus žymeklį", + "circle-click": "Paspaudus apskritimą", + "tooltip-tag-action": "Patarimo žymos veiksmas", + "node-selected": "Pasirinkus mazgą", + "element-click": "Paspaudus HTML elementą", + "pie-slice-click": "Paspaudus diagramos dalį", + "row-double-click": "Dukart paspaudus eilutę", + "cell-double-click": "Dukart paspaudus langelį", + "card-click": "Paspaudus kortelę", + "click": "Paspaudus" } + }, + "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š" + }, + "language": { + "language": "Kalba" + } } - diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index 6d49e9f698..62cde47c86 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -692,7 +692,7 @@ pre.tb-highlight { mat-toolbar.mat-mdc-table-toolbar:not(.mat-primary), .mat-mdc-cell, .mat-expansion-panel-header, mat-card-header.mat-mdc-card-header { button.mat-mdc-icon-button { .mat-icon { - color: rgba(0, 0, 0, .54); + color:var(--mat-icon-color, rgba(0, 0, 0, .54)); } &[disabled][disabled] { .mat-icon { @@ -760,11 +760,11 @@ pre.tb-highlight { transition: background-color .2s; &:hover:not(.tb-current-entity) { - background-color: #f4f4f4; + background-color: var(--tb-hover-color, #f4f4f4); } &.tb-current-entity { - background-color: #e9e9e9; + background-color: var(--tb-current-entity-color, #e9e9e9); } &.tb-pointer { @@ -816,7 +816,7 @@ pre.tb-highlight { vertical-align: middle; border-width: 0; border-bottom-width: 1px; - border-bottom-color: rgba(0, 0, 0, 0.12); + border-bottom-color: var(--mat-table-row-item-outline-color,rgba(0, 0, 0, 0.12)); border-style: solid; text-overflow: ellipsis; touch-action: auto !important; @@ -902,6 +902,9 @@ pre.tb-highlight { &.tb-mat-12 { @include tb-mat-icon-size(12); } + &.tb-mat-14 { + @include tb-mat-icon-size(14); + } &.tb-mat-16 { @include tb-mat-icon-size(16); }