diff --git a/README.md b/README.md index 27992d57bd..6267cc3a7a 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,9 @@ Check out our [Getting Started guide](https://thingsboard.io/docs/getting-starte

Provision, monitor and control your IoT entities in secure way using rich server-side APIs. Define relations between your devices, assets, customers or any other entities.

+
- Read more 🡪 + Read more ➜

@@ -56,8 +57,9 @@ Check out our [Getting Started guide](https://thingsboard.io/docs/getting-starte

Collect and store telemetry data in scalable and fault-tolerant way. Visualize your data with built-in or custom widgets and flexible dashboards. Share dashboards with your customers.

+
- Read more 🡪 + Read more ➜

@@ -72,12 +74,13 @@ Check out our [Getting Started guide](https://thingsboard.io/docs/getting-starte

Monitor and control your industrial processes in real time with SCADA. Use SCADA symbols on dashboards to create and manage any workflow, offering full flexibility to design and oversee operations according to your requirements.

+
- Read more 🡪 + Read more ➜

- +
Process and React @@ -87,8 +90,9 @@ Check out our [Getting Started guide](https://thingsboard.io/docs/getting-starte

Define data processing rule chains. Transform and normalize your device data. Raise alarms on incoming telemetry events, attribute updates, device inactivity and user actions.


+
- Read more 🡪 + Read more ➜

@@ -99,11 +103,11 @@ Check out our [Getting Started guide](https://thingsboard.io/docs/getting-starte ThingsBoard allows you to create complex [Rule Chains](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) to process data from your devices and match your application specific use cases. -[![IoT Rule Engine](https://github.com/user-attachments/assets/ccc048a8-5aa3-44dc-abd4-c20d1d833102 "IoT Rule Engine")](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) +[![IoT Rule Engine](https://github.com/user-attachments/assets/43d21dc9-0e18-4f1b-8f9a-b72004e12f07 "IoT Rule Engine")](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/)
-[**Read more about Rule Engine 🡪**](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) +[**Read more about Rule Engine ➜**](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/)
@@ -113,27 +117,27 @@ ThingsBoard is a scalable, user-friendly, and device-agnostic IoT platform that [**Smart energy**](https://thingsboard.io/use-cases/smart-energy/) -[![Smart energy](https://github.com/user-attachments/assets/7952d0f1-2ba4-4989-bfc9-75b40de6ea3f "Smart energy")](https://thingsboard.io/use-cases/smart-energy/) +[![Smart energy](https://github.com/user-attachments/assets/2a0abf13-6dc5-4f5e-9c30-1aea1d39af1e "Smart energy")](https://thingsboard.io/use-cases/smart-energy/) [**SCADA swimming pool**](https://thingsboard.io/use-cases/scada/) -[![SCADA Swimming pool](https://github.com/user-attachments/assets/b357c129-ea72-4b64-9dfe-ac25011603b6 "SCADA Swimming pool")](https://thingsboard.io/use-cases/scada/) +[![SCADA Swimming pool](https://github.com/user-attachments/assets/68fd9e29-99f1-4c16-8c4c-476f4ccb20c0 "SCADA Swimming pool")](https://thingsboard.io/use-cases/scada/) [**Fleet tracking**](https://thingsboard.io/use-cases/fleet-tracking/) -[![Fleet tracking](https://github.com/user-attachments/assets/80b63841-40c9-4db9-bec2-6a400dc6e58d "Fleet tracking")](https://thingsboard.io/use-cases/fleet-tracking/) +[![Fleet tracking](https://github.com/user-attachments/assets/9e8938ba-ee0c-4599-9494-d74b7de8a63d "Fleet tracking")](https://thingsboard.io/use-cases/fleet-tracking/) [**Smart farming**](https://thingsboard.io/use-cases/smart-farming/) -[![Smart farming](https://github.com/user-attachments/assets/8fe84ad6-6ea4-4cb1-bc31-6cd5c20c357b "Smart farming")](https://thingsboard.io/use-cases/smart-farming/) +[![Smart farming](https://github.com/user-attachments/assets/56b84c99-ef24-44e5-a903-b925b7f9d142 "Smart farming")](https://thingsboard.io/use-cases/smart-farming/) [**Smart metering**](https://thingsboard.io/smart-metering/) -[![Smart metering](https://github.com/user-attachments/assets/564e5ed0-afad-452c-a16c-6270b468ebdc "Smart metering")](https://thingsboard.io/smart-metering/) +[![Smart metering](https://github.com/user-attachments/assets/adc05e3d-397c-48ef-bed6-535bbd698455 "Smart metering")](https://thingsboard.io/smart-metering/)
-[**Check more of our use cases 🡪**](https://thingsboard.io/iot-use-cases/) +[**Check more of our use cases ➜**](https://thingsboard.io/iot-use-cases/)
diff --git a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json index a30d6ee76f..2701abcb84 100644 --- a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json @@ -13,6 +13,7 @@ "home_page_widgets.quick_links", "home_page_widgets.documentation_links", "home_page_widgets.dashboards", - "home_page_widgets.usage_info" + "home_page_widgets.usage_info", + "api_usage" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/api_usage.json b/application/src/main/data/json/system/widget_types/api_usage.json new file mode 100644 index 0000000000..9d6f2bc464 --- /dev/null +++ b/application/src/main/data/json/system/widget_types/api_usage.json @@ -0,0 +1,35 @@ +{ + "fqn": "api_usage", + "name": "API Usage", + "deprecated": false, + "image": "tb-image;/api/images/system/api-usage-widget.png", + "description": null, + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "\n", + "templateCss": "#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n margin: 5px;\n padding: 8px;\n}\n\n.tbDatasource-title {\n font-size: 1.200rem;\n font-weight: 500;\n padding-bottom: 10px;\n}\n\n.tbDatasource-table {\n width: 100%;\n box-shadow: 0 0 10px #ccc;\n border-collapse: collapse;\n white-space: nowrap;\n font-size: 1.000rem;\n color: #757575;\n}\n\n.tbDatasource-table td {\n position: relative;\n border-top: 1px solid rgba(0, 0, 0, 0.12);\n border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n padding: 0px 18px;\n box-sizing: border-box;\n}", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.apiUsageWidget.onInit();\n}\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n datasourcesOptional: true,\n previewWidth: '400px',\n previewHeight: '300px'\n };\n}", + "settingsForm": [], + "dataKeySettingsForm": [], + "settingsDirective": "tb-api-usage-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\":\"0\",\"settings\":{},\"title\":\"API usage\",\"decimals\":null,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"widgetCss\":\".tb-widget-header {\\n height: 48px;\\n align-items: center !important;\\n padding: 5px 10px 0 10px;\\n}\",\"titleStyle\":{},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"actions\":{\"headerButton\":[{\"name\":\"Go back\",\"buttonType\":\"stroked\",\"showIcon\":true,\"icon\":\"undo\",\"buttonColor\":\"#305680\",\"buttonBorderColor\":\"#0000001F\",\"customButtonStyle\":{\"padding\":\"0 16px\"},\"useShowWidgetActionFunction\":true,\"showWidgetActionFunction\":\"return widgetContext.stateController.getStateId() !== widgetContext.settings.targetDashboardState && widgetContext.settings.targetDashboardState;\",\"type\":\"custom\",\"customFunction\":\"const state = widgetContext.settings.targetDashboardState?.length ? widgetContext.settings.targetDashboardState : 'default';\\nwidgetContext.stateController.updateState(state, widgetContext.stateController.getStateParams(), false);\",\"openInSeparateDialog\":false,\"openInPopover\":false,\"id\":\"1ea1cca6-47d1-3539-d051-9535129fb12b\"}]},\"titleFont\":{\"size\":16,\"sizeUnit\":\"px\",\"family\":null,\"weight\":\"500\",\"style\":null,\"lineHeight\":\"21px\"},\"borderRadius\":\"4px\"}" + }, + "resources": [ + { + "link": "/api/images/system/api-usage-widget.png", + "title": "\"API Usage\" system widget image", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "api-usage-widget.png", + "publicResourceKey": "1TAYhxLWInZC20Xrn8PgbNbQs6fX8Pir", + "mediaType": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAChVBMVEUAAADs7Ozd3d3f4+Xd4eLN0tLP0dTg4+Xp6en////y9ffx9/Pt7e0ZgDjl7uvh5Oawvs/7+/vx8fHO1+H9/f31+ffu7u78/f3s8fH9/v6Eu5Xg7uX6/PxHaY7i5efI0t1VdJf5+vvx9PaNornv8vVOb5NffJ262cRphaNaeJrs7/Ouvc2Wqb+BmLKmpqZjgJ9ip3ft8PXBzdmsu8yEm7Rviaf3+Pra4empqann7PHQ2eK/y9i3xNPD3stXdpi8vLyGnbVsh6VRcZV+t5A7X4ff5evW3ebU3eXN1+GzwdHOzs6pucuarcGQpbt7k66OwZ46klXc7OKcrsKx1Lytrq53kaxyjKhDZYs+Yoni5+3L4tOitMeKn7dmgqH09vn09PS6x9V1jqrm6/DT2+TEz9vX2NnV1dbHyMmltsisrKxhfp5cepuJvplLbZHj6e7d3d2hssWer8PBwcGoz7R0sojp7fLj8Oe/3MiYq8C4ubm1tbWvsLCeyauCuZQ3XIRepnTl6u/p8+zc4+rk5OTK1N/O5Na4xdSxv9CTp71+lrCSw6F4tItnq30cgTr29vbt8vTq6urh4eHU59rR5tefn6CMv5zd5OvX3+fIy8yt0riHvZj19/nm5+ja2tvR09S+v8B7to1ur4FkqXlPnmdHmWDz9fjZ4Oi8ydagscOVxaRxsYQqiUchhD/3+/jq8O/o6uzZ6t7Ly8uw07yysrKjzLCho6SVlpZao3Le4eOx1LuozbeZx6dWoW7t9e/c3uDC3czKysrDw8OytbaZm5xrrn8wjUze7eLO0NDI4c8zWYLg5uzV5t7FxcWOjo7v9vHY5+C01r7AydLKzc6nzLWBgoNCllzu8fMzg2RWAAAACXRSTlMA3Vrh2mBe3tXDgts5AAAPHUlEQVR42u3ch1sTZxzAcbr7eyEWaGw8uDR7r0JCAgkz7BFk7733kr2UvYTK0jpQqnW3bm3VWrXaveff0/cYLbXYQgiS0Hyf5y4hPjzw8T1y8X5EBweHl154zcZ74WUH3ItcZPPxX8TrsQ0cWPKywwtoW/SKw5toW/SaHWLG17qE0OVj6LWPEHrzE4SjdsWlP6PLpcdQyWV8/7JNQAK++wiVPnoHBdxC6GYHfoCbhPC94yVoZm6G++hTxP/1uE1A0Kd4Kd7BkEcfXZ6hIMe/wLtb5SV8JSq/fPMm951HNgZ5J6ntZwrSwacOr12PdilR0uWZ0i9unrAxSIny5msYculT/Bj/BJrhz6Cb/Jnfb31qK5B3jiFUXIy++Kj4i8ulCJ14DeG+TSpF7+CtDZ04dvwT24A80S682ebT75PZIf8XSHgrzgf9Wz7W+0p5BaR3yD9rSIreRsgD4fDt4h3+0gd4l1pA3XL5Cx/zKZXH0oYfw4/89Xmr1HmRtnof7jC7XasfWkG+SMZkR7CFPJSWwnTeJxF7IWZQtTM3v9JfF10tqe9ORuhgWlBqsiQIydjGojNslQKliCXpkf5sGUrx98+SqiqDuMMV7Aj0ZDvGaZaG4LhPhaSgM2d9U1Gajyw5wsjyfZsZGuHPqkB1muh8tLAiB5kosJXbzU11jgnKq4yI5Kb6eobqz9ar9GyUkBWdbWCzhOdYhejJ4mmbAeE/FRKLyoIiQ1CaNDIfFSn8+cyaXkFeChpKjlYsQaoRrx6FvN0tb1WjvORsFMYzhms09RUsFTqXlSBsbQ2riWHnoSd7+7NnuiIYMuyVtQgpqpQxm5iB+RofQb0kj4IY6/6E8PNj5ZlD2dHVPgK1pChFk18prW6tzAqvrsv3kWSm1KF/9MP8W6u3e5fZcdGqkKJQ1NuP+JEsX74v9yALva4+g5iGIT5Kl0UgHT7sC4cQkuYhfRiK5HIHIz0QS40/RcZCPrKIQaSXKWKQTq1HPs5nuciMNvU8wqxBay1ZVRGK1pP9hGg9kG/wCcDdHS106RtkRu6NcXFjO7YE8nFwcMby/a4fELp2By00fQStv0txNFzcjq2AnMTb7tkLlwaCg3d1BTfGX7uzo2vePb5xzBzILOUYCx7bohXZtf/S3ZaM+2MtXQPfdF27s/vGndngbzLMgXx46iJt7JfxuK1akf2oU3Tk8BFR1w87Gq/dCT5/J6Nxx7R5kPtuv4zT4rZoRe5TkLuNe691Ne6/f+3OwP7g+/f3N5oD2U071YlfvAVv6bPW28u7hT3fvH8nL/6wX0I2fx7ZEfzbb8GXtvw8spTtnxCRHWKH2EqvOZxz3hadc/jp9W3RTw5vwLbIyQ6xsuwQa+uZQhjAYcDChveAc8GPuQIQrgRw6IA3m4AcKC+GpACAR1GUqQ0gt+Mm0JUNbdDxsBxmHgB8V2sTkKSSY5CkJJxuUZDjkxhDeMOBE+CNbzq03t4wecs2ILdqla5JD4rbSilIO1Dhb/5bUHKSoN1V2dDX0WAZiJSF48Aa6meBGSnh28mkXO/yEgxxfbgEcQpYWJFyhjLX+3qpZSA6kyDFhODfEucAVUIMUNUZYD2VXE+it00EFM+VADTkAiyuy/X2B3CiPQCuQ8exqEkLHVpentCvEEuTxXmQwhMWkpqgTEjWqMJBXaE5ylJ4qbsDqe9fpYrJGRGrdQJJ05A4FoEZHYPVsxzEV9VU2B/KhLR0tUJfqW0lmb2e2aFsjilmKFsKTARQI9AGxvQaPNLoWTIoOz3iDFbRPyAKYKl4aZDGORtL1FdqgMmXCjyTwRA7pIEFSL8XPrQOCXndnCwZka8QZoJVtArEZDKELED6swpSEXOwLqWpOjw5moJU6wE8mIVeMWWK/m6tadgj7YzYGiGRB+HMWfDglcnJVlI3SKg1BmAm8KQQERhNhBkA8oYBoChQzcoZjkngfq3xyQtUF4FV9J/nESYfLNTAyfeogndarg/oa4foGGCZPlgas+91tGTEsz+z99A2A8J49pCd7z/7FTkkhU1I9F4cVeNOC8aAf4XkHQIqdSTYTisgHrFByac5gRVlkMnKC8zOSjey04fEIwhsohWQfQJSk5mZRUrCFJF1I2QqX54JzFC8OjbRSkglyLI0vqAYxBA5CAowhBWUXQBWGf1244UfnwJhhnk5R6aECWqWIAmBSJweKwML5ebmNgVLVTUDiBJhofjdsP6IvTTcSfqqELbpHJ2QBbLg7KEIFmSiAk26PjCBAxZqP95aDncSjuf3EFWd5z8QJdL3dJLk+fPmQA5TjlPzd59yaG1qH7q5kY/jdw/4NY9OV/n5nRcl7hG13Djv12IO5ORn47RTGRffWw1ChsKmRq3Iu7DH7/bsBb+q5p1josSuI6OiRtKsQ+vjC9c+zrhIi9uKiw97ExPdKYjb4f0tVaOziaLEFrfbGYmzu82BdNHmp/CLt8dbAYmfnr7XDN/vJDN6vu9pngJHR/h8AGDKvcecV24Xafglz/vNtn/JdPoU/mG/si2u/X6w034R25qzQ6wtJ4cDLtuiAw6Trtuiye1zaNkh1pUdYm3ZIeuvVukEtR1JLn0d5W9MKgGKywH3kAMQ0BYAJcpyp76k8omS6wBRbVYNORZwALyJqOIoeu2Due+coNwbAOjtAC5R4A0NfQwodymOivpuApRJVg0BDFHCXBQQSblzAW2ubUoAKK4F3IF2uNo246qEvodRAQ/7AiwFOcqC1SLQhiHeUFICAZ/AXGl5mxMFaSfwzrXcBd6A4ihvovZqVIn3daeNQdBXEnYmUKWzYbV6szYM+bZBmXt1pnRurrR4BjAktwEAiFsNpa4ND5VOUQHeb0SVPCif2CAkFXJCGDHkQV8M2WcqYwAAi8eC6EGeDvQ8Z899Mm1dtPwonOXpwZwmGABOE5Dr5JTLmSDwHYA+Vwri5OTkQv0JvJELHA5DS+RuDJLGKmND6lHP5HQ2YfRUlAHo2Hp2jTChTHyayYrlRYg9uotS1CxVhMD6rmyvhHSb0g4tQQpC8lWBAGWCfKOn8FCvpDAb1BSEiUfTw+x8QThYW38/tDJHgOkjwxBtqtSjAMBTTNeRFKSpWhuzDFHHwiECrK0VkKNi4GRLoyXJJp9kMFRkhwEQw8LknJGva2Ihs1LIOxTI9QJnGRko1JBgba31PCI3VHjCxnK867aG9jDWE7FuyMGyfthYjDjaWtprM+Npy0Lozx7iaAUQLlii2XHLQz6AtUOGwkBMWuZirfsa6rHAs1YhLw/KCvSDRKRJB+kmNWEIB7mO7ZUjp5+OkfPpCc4mLhFtCgNrbAWEK4io0PumsHVDKSwBR1AUKwt0hm6uVz0ZQnplyirIrwyx9Z7i8CCwxlZACpn5EhkYY2GkMp+pZwMABYGRQQgh00gI4YRANE+aLTaAVdS8Z0/zU8bT/pxQbYREIJWbIEzLzPFk8TL5X4HCF0MkYenVJAXRFzWlEWBWN1aMpzt7AL5sWfqOboAZjeKnjvHRVSGQKUyRxoZHJuTECk3EoDDfQ1eZXwEGSZOKEZatimCoIK/MI1lcv4Fh6JX4aSAy4qHqywFqzn5lGmDArKluBg332d4vt2QYeuMG+fjwu/GJtxs/rzo/eliU6Dc7Kjo8OmoO5MI4dgy8d2ErICfd3enUVHd69/7l8fR8cKe54+muYOygfbwlEEdHkoKMuXf5VYla7uJfGLjdfOV8i8gcyNj44fj3aLTGrYDgg2hKBFPNPaOiK18mdrp8Hk8/fNeR7LydAetvanw8Dv+0T9n+lcY91Jh9z3a4ZLqzpcXRfu3XirNDrK1tBKl12hbVbp8VsUOsKzvE2rJD1t9kexTgXCcBXL6YBE5b28QbJwCcGmwNoiQ6cvHNCbybO3EVSj/pa+j71RXaZ2wNgmfpxwCIdsDVXoX2XG1H3/UAl3LvrYRwPcyFzBUvQ1wnrvc1eEcVKzcBkhUBK6ObKlN8YKnYUKAKL8A7ffUg/K1+bk72f0IOKDkA7fQlyPHSq1F9DcdvEZsB8fKEQk+ttBcONmkNhVBUQUSOQJOhCfS6dH1OaKiB7yEe5oBWMSIt1OaEcwrDWQSkG1ABW032A6NID9rwwn5YvdwHBwBcShfv469aUkJMHGD0weSmQAa9hr1C/UFyMKi+wnMfs04KPsZzRq4g6HVVuCZ72FgjVByF014p6f5fhwl9QlrZBr1/jP8hgZzPhJQsr5jQtBgjC8zI0pDT9SNMkBT6QzivUg7SmGpfNQ90SFAAGBIJ7BqFLwAkJMACRADyOp4aCklVGJ+ZkwpNAvyXwIsGM7Ig5IwMKvRZ5/AIuk5ST6Q21cvz1MBS+WqgiC/woSDOBFO6DJH0Fi5AzskzYXABQqaSvf7WAPEwqsSkrzEojfD5qgDE2ewsqVBsjNRWiLM5ixB/YSyUGX0WIHJ/1SKkwD87FgIlC362pzVAAHLwpqWv+ABOk9QAHhbTRFL3EAELnYblji5P5zgk/FsE3dIR5p1HZBEbm7w5Wr6dW3BmJxzXltWPp7cNBB9amxCsHRLuA0LSMmtCt3iwKoRrqIHXtdIzEGrIAY4nvm0ClodYzmERRD/eInRFdDhjsL5fOnsCwjHWG9Nbh8We/ZJWIaEaFhZRU919Qo02hMFLVijIr+RCtV5SxgZrbAUkTGCqVHMEXqAIMqWFG6GpZnE8nQchjFQtPW1hPK0zxuwDq4j4PJ6xOkQnrNGhfdVGTqC8RufBJHrDsmT0JUi1NCeVpCAHw84yCTCraT+/HliKGm1cmYKF7vmBGWXE4WnotVUhkJItqQkaHJbXsIO8CFMFW1dUHdQNZWxpCMOX7a9egOgk+V5gXm9RELoLAEmHqmYXajzNIAFczBqGxr9PwxOruIGnvEShw2IIb1oCb4zl1yQkZ3lFT29oqjv7Yc+PwfvvVc0/zhAlxl+4MJ3xeMwcyEnKIXp8cqvm7NRU995d6t3TjvOiRLcutyNjO81akbjGU++L3t2ad08vvw187F6nX9WVz6tEiaNTjO+PuE+ZAzl1MePLd/G7dbcCMuvmNtAJflcy5qsGbs/O90xnOLp1xd+bn/3RrP9h4OJb+OjqtP0rjcE03F7S9iGQUXVERNiv/Vpvdoi1tY0gz8G26DmH5+mwDaI/7+Dw3DaQ0F91wD3/nJON9xxejz8ATqyBto+N2i0AAAAASUVORK5CYII=", + "public": true + } + ], + "scada": false, + "tags": null +} \ No newline at end of file diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 016e786776..320d3e5bdd 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -14,3 +14,35 @@ -- limitations under the License. -- +-- UPDATE TENANT PROFILE CONFIGURATION START + +UPDATE tenant_profile +SET profile_data = jsonb_set( + profile_data, + '{configuration}', + (profile_data -> 'configuration') + || jsonb_strip_nulls( + jsonb_build_object( + 'minAllowedScheduledUpdateIntervalInSecForCF', + CASE + WHEN (profile_data -> 'configuration') ? 'minAllowedScheduledUpdateIntervalInSecForCF' + THEN NULL + ELSE to_jsonb(3600) + END, + 'maxRelationLevelPerCfArgument', + CASE + WHEN (profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument' + THEN NULL + ELSE to_jsonb(10) + END + ) + ), + false + ) +WHERE NOT ( + (profile_data -> 'configuration') ? 'minAllowedScheduledUpdateIntervalInSecForCF' + AND + (profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument' + ); + +-- UPDATE TENANT PROFILE CONFIGURATION END diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldDynamicArgumentsRefreshMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldDynamicArgumentsRefreshMsg.java new file mode 100644 index 0000000000..301fe22dfb --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldDynamicArgumentsRefreshMsg.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.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; + +@Data +public class CalculatedFieldDynamicArgumentsRefreshMsg implements ToCalculatedFieldSystemMsg { + + private final TenantId tenantId; + private final CalculatedFieldId cfId; + + @Override + public MsgType getMsgType() { + return MsgType.CF_DYNAMIC_ARGUMENTS_REFRESH_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 2959bfc8eb..2a5f3c3cfd 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 @@ -51,7 +51,7 @@ public class CalculatedFieldEntityActor extends AbstractCalculatedFieldActor { @Override public void destroy(TbActorStopReason stopReason, Throwable cause) throws TbActorException { log.debug("[{}] Stopping CF entity actor.", processor.tenantId); - processor.stop(); + processor.stop(false); } @Override @@ -75,6 +75,9 @@ public class CalculatedFieldEntityActor extends AbstractCalculatedFieldActor { case CF_LINKED_TELEMETRY_MSG: processor.process((EntityCalculatedFieldLinkedTelemetryMsg) msg); break; + case CF_ENTITY_DYNAMIC_ARGUMENTS_REFRESH_MSG: + processor.process((EntityCalculatedFieldDynamicArgumentsRefreshMsg) msg); + break; default: return false; } 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 35539834c3..7513ca41e2 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 @@ -48,6 +48,7 @@ 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 java.util.ArrayList; import java.util.Collection; @@ -91,16 +92,18 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM this.ctx = ctx; } - public void stop() { - log.info("[{}][{}] Stopping entity actor.", tenantId, entityId); + public void stop(boolean partitionChanged) { + log.info(partitionChanged ? + "[{}][{}] Stopping entity actor due to change partition event." : + "[{}][{}] Stopping entity actor.", + tenantId, entityId); states.clear(); ctx.stop(ctx.getSelf()); } public void process(CalculatedFieldPartitionChangeMsg msg) { if (!systemContext.getPartitionService().resolve(ServiceType.TB_RULE_ENGINE, DataConstants.CF_QUEUE_NAME, tenantId, entityId).isMyPartition()) { - log.info("[{}] Stopping entity actor due to change partition event.", entityId); - ctx.stop(ctx.getSelf()); + stop(true); } } @@ -224,6 +227,18 @@ 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()); + } else { + currentState.setDirty(true); + log.debug("[{}][{}] CF state marked as dirty.", entityId, msg.getCfId()); + } + msg.getCallback().onSuccess(); + } + private void processTelemetry(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException { processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArguments(ctx, proto.getTsDataList()), toTbMsgId(proto), toTbMsgType(proto)); } @@ -251,6 +266,15 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM if (state == null) { state = getOrInitState(ctx); justRestored = true; + } else if (state.isDirty()) { + log.debug("[{}][{}] Going to update dirty CF state.", entityId, ctx.getCfId()); + try { + Map dynamicArgsFromDb = cfService.fetchDynamicArgsFromDb(ctx, entityId); + dynamicArgsFromDb.forEach(newArgValues::putIfAbsent); + state.setDirty(false); + } catch (Exception e) { + throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); + } } if (state.isSizeOk()) { if (state.updateState(ctx, newArgValues) || justRestored) { @@ -271,7 +295,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM if (state != null) { return state; } else { - ListenableFuture stateFuture = systemContext.getCalculatedFieldProcessingService().fetchStateFromDb(ctx, entityId); + 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, @@ -288,7 +312,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM boolean stateSizeChecked = false; try { if (ctx.isInitialized() && state.isReady()) { - CalculatedFieldResult calculationResult = state.performCalculation(ctx).get(systemContext.getCfCalculationResultTimeout(), TimeUnit.SECONDS); + CalculatedFieldResult calculationResult = state.performCalculation(entityId, ctx).get(systemContext.getCfCalculationResultTimeout(), TimeUnit.SECONDS); state.checkStateSize(ctxId, ctx.getMaxStateSize()); stateSizeChecked = true; if (state.isSizeOk()) { @@ -298,7 +322,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM callback.onSuccess(); } if (DebugModeUtil.isDebugAllAvailable(ctx.getCalculatedField())) { - systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, calculationResult.getResult().toString(), null); + systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, calculationResult.toStringOrElseNull(), null); } } } else { @@ -367,7 +391,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } private Map mapToArguments(CalculatedFieldCtx ctx, AttributeScopeProto scope, List attrDataList) { - return mapToArguments(ctx.getMainEntityArguments(), scope, attrDataList); + return mapToArguments(entityId, ctx.getMainEntityArguments(), ctx.getMainEntityGeofencingArgumentNames(), scope, attrDataList); } private Map mapToArguments(CalculatedFieldCtx ctx, EntityId entityId, AttributeScopeProto scope, List attrDataList) { @@ -375,17 +399,23 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM if (argNames.isEmpty()) { return Collections.emptyMap(); } - return mapToArguments(argNames, scope, attrDataList); + List geofencingArgumentNames = ctx.getLinkedEntityGeofencingArgumentNames(); + return mapToArguments(entityId, argNames, geofencingArgumentNames, scope, attrDataList); } - private Map mapToArguments(Map argNames, AttributeScopeProto scope, List attrDataList) { + private Map mapToArguments(EntityId entityId, Map argNames, List geoArgNames, AttributeScopeProto scope, List attrDataList) { 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) { - arguments.put(argName, new SingleValueArgumentEntry(item)); + if (argName == null) { + continue; + } + if (geoArgNames.contains(argName)) { + arguments.put(argName, new GeofencingArgumentEntry(entityId, item)); + continue; } + arguments.put(argName, new SingleValueArgumentEntry(item)); } return arguments; } 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 7d2ae0ff44..ab6cb34176 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 @@ -79,6 +79,9 @@ public class CalculatedFieldManagerActor extends AbstractCalculatedFieldActor { 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 c00995d3d4..75ca0b4c9b 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 @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ProfileEntityIdInfo; 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.id.AssetId; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.DeviceId; @@ -56,7 +57,11 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; import static org.thingsboard.server.utils.CalculatedFieldUtils.fromProto; @@ -69,6 +74,7 @@ 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 CalculatedFieldProcessingService cfExecService; private final CalculatedFieldStateService cfStateService; @@ -107,6 +113,8 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware calculatedFields.clear(); entityIdCalculatedFields.clear(); entityIdCalculatedFieldLinks.clear(); + cfDynamicArgumentsRefreshTasks.values().forEach(future -> future.cancel(true)); + cfDynamicArgumentsRefreshTasks.clear(); ctx.stop(ctx.getSelf()); } @@ -255,7 +263,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware log.debug("[{}] Failed to lookup CF by id [{}]", tenantId, cfId); callback.onSuccess(); } else { - var cfCtx = new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService()); + var cfCtx = getCfCtx(cf); try { cfCtx.init(); } catch (Exception e) { @@ -266,11 +274,16 @@ 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); - initCf(cfCtx, callback, false); + scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(cfCtx); + applyToTargetCfEntityActors(cfCtx, callback, (id, cb) -> initCfForEntity(id, cfCtx, false, cb)); } } } + private CalculatedFieldCtx getCfCtx(CalculatedField cf) { + return new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService(), systemContext.getRelationService()); + } + private void onCfUpdated(ComponentLifecycleMsg msg, TbCallback callback) throws CalculatedFieldException { var cfId = new CalculatedFieldId(msg.getEntityId().getId()); var oldCfCtx = calculatedFields.get(cfId); @@ -282,7 +295,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware log.debug("[{}] Failed to lookup CF by id [{}]", tenantId, cfId); callback.onSuccess(); } else { - var newCfCtx = new CalculatedFieldCtx(newCf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService()); + var newCfCtx = getCfCtx(newCf); try { newCfCtx.init(); } catch (Exception e) { @@ -290,6 +303,13 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware } 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) { @@ -312,7 +332,7 @@ 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) var stateChanges = newCfCtx.hasStateChanges(oldCfCtx); if (stateChanges || newCfCtx.hasOtherSignificantChanges(oldCfCtx)) { - initCf(newCfCtx, callback, stateChanges); + applyToTargetCfEntityActors(newCfCtx, callback, (id, cb) -> initCfForEntity(id, newCfCtx, stateChanges, cb)); } else { callback.onSuccess(); } @@ -326,30 +346,20 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware if (cfCtx == null) { log.debug("[{}] CF was already deleted [{}]", tenantId, cfId); callback.onSuccess(); - } else { - entityIdCalculatedFields.get(cfCtx.getEntityId()).remove(cfCtx); - deleteLinks(cfCtx); - - EntityId entityId = cfCtx.getEntityId(); - EntityType entityType = cfCtx.getEntityId().getEntityType(); - if (isProfileEntity(entityType)) { - var entityIds = entityProfileCache.getEntityIdsByProfileId(entityId); - if (!entityIds.isEmpty()) { - //TODO: no need to do this if we cache all created actors and know which one belong to us; - var multiCallback = new MultipleTbCallback(entityIds.size(), callback); - entityIds.forEach(id -> { - if (isMyPartition(id, multiCallback)) { - deleteCfForEntity(id, cfId, multiCallback); - } - }); - } else { - callback.onSuccess(); - } - } else { - if (isMyPartition(entityId, callback)) { - deleteCfForEntity(entityId, cfId, callback); - } - } + return; + } + entityIdCalculatedFields.get(cfCtx.getEntityId()).remove(cfCtx); + deleteLinks(cfCtx); + cancelCfDynamicArgumentsRefreshTaskIfExists(cfId, true); + applyToTargetCfEntityActors(cfCtx, callback, (id, cb) -> deleteCfForEntity(id, cfId, cb)); + } + + 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); } } @@ -389,31 +399,11 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware } for (var linkProto : linksList) { var link = fromProto(linkProto); - var targetEntityId = link.entityId(); - var targetEntityType = targetEntityId.getEntityType(); var cf = calculatedFields.get(link.cfId()); - if (EntityType.DEVICE_PROFILE.equals(targetEntityType) || EntityType.ASSET_PROFILE.equals(targetEntityType)) { - // iterate over all entities that belong to profile and push the message for corresponding CF - var entityIds = entityProfileCache.getEntityIdsByProfileId(targetEntityId); - if (!entityIds.isEmpty()) { - MultipleTbCallback multipleCallback = new MultipleTbCallback(entityIds.size(), callback); - var newMsg = new EntityCalculatedFieldLinkedTelemetryMsg(tenantId, sourceEntityId, proto.getMsg(), cf, multipleCallback); - entityIds.forEach(entityId -> { - if (isMyPartition(entityId, multipleCallback)) { - log.debug("Pushing linked telemetry msg to specific actor [{}]", entityId); - getOrCreateActor(entityId).tell(newMsg); - } - }); - } else { - callback.onSuccess(); - } - } else { - if (isMyPartition(targetEntityId, callback)) { - log.debug("Pushing linked telemetry msg to specific actor [{}]", targetEntityId); - var newMsg = new EntityCalculatedFieldLinkedTelemetryMsg(tenantId, sourceEntityId, proto.getMsg(), cf, callback); - getOrCreateActor(targetEntityId).tell(newMsg); - } - } + withTargetEntities(link.entityId(), callback, (ids, cb) -> { + var linkedTelemetryMsg = new EntityCalculatedFieldLinkedTelemetryMsg(tenantId, sourceEntityId, proto.getMsg(), cf, cb); + ids.forEach(id -> linkedTelemetryMsgForEntity(id, linkedTelemetryMsg)); + }); } } @@ -452,26 +442,46 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware return result; } - private void initCf(CalculatedFieldCtx cfCtx, TbCallback callback, boolean forceStateReinit) { - EntityId entityId = cfCtx.getEntityId(); - EntityType entityType = cfCtx.getEntityId().getEntityType(); - if (isProfileEntity(entityType)) { - var entityIds = entityProfileCache.getEntityIdsByProfileId(entityId); - if (!entityIds.isEmpty()) { - var multiCallback = new MultipleTbCallback(entityIds.size(), callback); - entityIds.forEach(id -> { - if (isMyPartition(id, multiCallback)) { - initCfForEntity(id, cfCtx, forceStateReinit, multiCallback); - } - }); - } else { - callback.onSuccess(); - } - } else { - if (isMyPartition(entityId, callback)) { - initCfForEntity(entityId, cfCtx, forceStateReinit, callback); - } + private void scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(CalculatedFieldCtx cfCtx) { + CalculatedField cf = cfCtx.getCalculatedField(); + if (!(cf.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledCfConfig)) { + return; + } + if (!scheduledCfConfig.isScheduledUpdateEnabled()) { + return; } + if (cfDynamicArgumentsRefreshTasks.containsKey(cf.getId())) { + log.debug("[{}][{}] Dynamic arguments refresh task for CF already exists!", tenantId, cf.getId()); + return; + } + 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()); + } + + 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 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 linkedTelemetryMsgForEntity(EntityId entityId, EntityCalculatedFieldLinkedTelemetryMsg msg) { + log.debug("Pushing linked telemetry msg to specific actor [{}]", entityId); + getOrCreateActor(entityId).tell(msg); } private void deleteCfForEntity(EntityId entityId, CalculatedFieldId cfId, TbCallback callback) { @@ -545,7 +555,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware } private void initCalculatedField(CalculatedField cf) throws CalculatedFieldException { - var cfCtx = new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService()); + var cfCtx = new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService(), systemContext.getRelationService()); try { cfCtx.init(); } catch (Exception e) { @@ -555,6 +565,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware // 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) { @@ -584,4 +595,30 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware } } + private void applyToTargetCfEntityActors(CalculatedFieldCtx ctx, + TbCallback callback, + BiConsumer action) { + withTargetEntities(ctx.getEntityId(), callback, (ids, cb) -> ids.forEach(id -> action.accept(id, cb))); + } + + private void withTargetEntities(EntityId entityId, TbCallback parentCallback, BiConsumer, TbCallback> consumer) { + if (isProfileEntity(entityId.getEntityType())) { + var ids = entityProfileCache.getEntityIdsByProfileId(entityId); + if (ids.isEmpty()) { + parentCallback.onSuccess(); + return; + } + var multiCallback = new MultipleTbCallback(ids.size(), parentCallback); + var profileEntityIds = ids.stream().filter(id -> isMyPartition(id, multiCallback)).toList(); + if (profileEntityIds.isEmpty()) { + return; + } + consumer.accept(profileEntityIds, multiCallback); + return; + } + if (isMyPartition(entityId, parentCallback)) { + consumer.accept(List.of(entityId), parentCallback); + } + } + } diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldDynamicArgumentsRefreshMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldDynamicArgumentsRefreshMsg.java new file mode 100644 index 0000000000..fdf864611f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldDynamicArgumentsRefreshMsg.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.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; + +@Data +public class EntityCalculatedFieldDynamicArgumentsRefreshMsg implements ToCalculatedFieldSystemMsg { + + private final TenantId tenantId; + private final CalculatedFieldId cfId; + private final TbCallback callback; + + @Override + public MsgType getMsgType() { + return MsgType.CF_ENTITY_DYNAMIC_ARGUMENTS_REFRESH_MSG; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/MultipleTbCallback.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/MultipleTbCallback.java index d1f4c9092e..493985c97a 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/MultipleTbCallback.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/MultipleTbCallback.java @@ -50,7 +50,7 @@ public class MultipleTbCallback implements TbCallback { @Override public void onFailure(Throwable t) { - log.warn("[{}][{}] onFailure.", id, callback.getId()); + log.warn("[{}][{}] onFailure.", id, callback.getId(), t); callback.onFailure(t); } } diff --git a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java index 067a1e98c6..410b05456e 100644 --- a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java @@ -116,6 +116,8 @@ public class SwaggerConfiguration { private String appVersion; @Value("${swagger.group_name:thingsboard}") private String groupName; + @Value("${swagger.doc_expansion:list}") + private String docExpansion; @Bean public OpenAPI thingsboardApi() { @@ -172,7 +174,7 @@ public class SwaggerConfiguration { uiProperties.setDefaultModelExpandDepth(1); uiProperties.setDefaultModelRendering("example"); uiProperties.setDisplayRequestDuration(false); - uiProperties.setDocExpansion("list"); + uiProperties.setDocExpansion(docExpansion); uiProperties.setFilter("false"); uiProperties.setMaxDisplayedTags(null); uiProperties.setOperationsSorter("alpha"); diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java index 2fbc89a84d..ca741ea8c6 100644 --- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java @@ -31,6 +31,7 @@ import org.springframework.security.config.annotation.method.configuration.Enabl import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.config.annotation.web.configurers.RequestCacheConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; @@ -38,6 +39,7 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.header.writers.CrossOriginOpenerPolicyHeaderWriter.CrossOriginOpenerPolicy; import org.springframework.security.web.header.writers.StaticHeadersWriter; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; @@ -210,9 +212,8 @@ public class ThingsboardSecurityConfiguration { @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.headers(headers -> headers - .cacheControl(config -> {}) - .frameOptions(config -> {}).disable()) + http.headers(headers -> headers.defaultsDisabled() + .crossOriginOpenerPolicy(coop -> coop.policy(CrossOriginOpenerPolicy.SAME_ORIGIN))) .cors(cors -> {}) .csrf(AbstractHttpConfigurer::disable) .exceptionHandling(config -> {}) 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 5945355ef8..c5b077c128 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java @@ -289,7 +289,6 @@ public class CalculatedFieldController extends BaseController { default -> throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities."); } } - } } 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 29f4daa783..b9968aefa9 100644 --- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java +++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java @@ -162,6 +162,8 @@ public class SystemInfoController extends BaseController { } systemParams.setMaxArgumentsPerCF(tenantProfileConfiguration.getMaxArgumentsPerCF()); systemParams.setMaxDataPointsPerRollingArg(tenantProfileConfiguration.getMaxDataPointsPerRollingArg()); + systemParams.setMinAllowedScheduledUpdateIntervalInSecForCF(tenantProfileConfiguration.getMinAllowedScheduledUpdateIntervalInSecForCF()); + systemParams.setMaxRelationLevelPerCfArgument(tenantProfileConfiguration.getMaxRelationLevelPerCfArgument()); 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/service/cf/AbstractCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java new file mode 100644 index 0000000000..45305ca9e3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java @@ -0,0 +1,257 @@ +/** + * 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.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.ThingsBoardExecutors; +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.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.Aggregation; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +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.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.relation.RelationService; +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 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.stream.Collectors; + +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.createDefaultKvEntry; +import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createStateByType; +import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformSingleValueArgument; + +@Data +@Slf4j +public abstract class AbstractCalculatedFieldProcessingService { + + protected final AttributesService attributesService; + protected final TimeseriesService timeseriesService; + protected final ApiLimitService apiLimitService; + protected final RelationService relationService; + + protected ListeningExecutorService calculatedFieldCallbackExecutor; + + @PostConstruct + public void init() { + calculatedFieldCallbackExecutor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool( + Math.max(4, Runtime.getRuntime().availableProcessors()), getExecutorNamePrefix())); + } + + @PreDestroy + public void stop() { + if (calculatedFieldCallbackExecutor != null) { + calculatedFieldCallbackExecutor.shutdownNow(); + } + } + + 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; + } + }; + return Futures.whenAllComplete(argFutures.values()).call(() -> { + var result = createStateByType(ctx); + result.updateState(ctx, resolveArgumentFutures(argFutures)); + return result; + }, MoreExecutors.directExecutor()); + } + + protected EntityId resolveEntityId(EntityId entityId, Argument argument) { + return argument.getRefEntityId() != null ? argument.getRefEntityId() : entityId; + } + + protected Map resolveArgumentFutures(Map> argFutures) { + return argFutures.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, // Keep the key as is + entry -> { + try { + return entry.getValue().get(); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + throw new RuntimeException("Failed to fetch " + entry.getKey() + ": " + cause.getMessage(), cause); + } catch (InterruptedException e) { + throw new RuntimeException("Failed to fetch" + entry.getKey(), e); + } + } + )); + } + + protected Map> fetchGeofencingCalculatedFieldArguments(CalculatedFieldCtx ctx, EntityId entityId, boolean dynamicArgumentsOnly) { + Map> argFutures = new HashMap<>(); + Set> entries = ctx.getArguments().entrySet(); + if (dynamicArgumentsOnly) { + entries = entries.stream() + .filter(entry -> entry.getValue().hasDynamicSource()) + .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())); + default -> { + var resolvedEntityIdsFuture = resolveGeofencingEntityIds(ctx.getTenantId(), entityId, entry); + argFutures.put(entry.getKey(), Futures.transformAsync(resolvedEntityIdsFuture, resolvedEntityIds -> + fetchGeofencingKvEntry(ctx.getTenantId(), resolvedEntityIds, entry.getValue()), MoreExecutors.directExecutor())); + } + } + } + return argFutures; + } + + private ListenableFuture> resolveGeofencingEntityIds(TenantId tenantId, EntityId entityId, Map.Entry entry) { + Argument value = entry.getValue(); + if (value.getRefEntityId() != null) { + return Futures.immediateFuture(List.of(value.getRefEntityId())); + } + if (!value.hasDynamicSource()) { + return Futures.immediateFuture(List.of(entityId)); + } + 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)), + configuration::resolveEntityIds, calculatedFieldCallbackExecutor); + } + }; + } + + 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()); + } + List>> kvFutures = geofencingEntities.stream() + .map(entityId -> { + var attributesFuture = attributesService.find( + tenantId, + entityId, + argument.getRefEntityKey().getScope(), + argument.getRefEntityKey().getKey() + ); + return Futures.transform(attributesFuture, resultOpt -> + Map.entry(entityId, resultOpt.orElseGet(() -> + new BaseAttributeKvEntry(createDefaultKvEntry(argument), System.currentTimeMillis(), 0L))), + calculatedFieldCallbackExecutor + ); + }).collect(Collectors.toList()); + + ListenableFuture>> allFutures = Futures.allAsList(kvFutures); + + return Futures.transform(allFutures, entries -> ArgumentEntry.createGeofencingValueArgument(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); + case ATTRIBUTE -> fetchAttribute(tenantId, entityId, argument, startTs); + case TS_LATEST -> fetchTsLatest(tenantId, entityId, argument, startTs); + }; + } + + private ListenableFuture fetchTsRolling(TenantId tenantId, EntityId entityId, Argument argument, long queryEndTs) { + long argTimeWindow = argument.getTimeWindow() == 0 ? queryEndTs : argument.getTimeWindow(); + long startInterval = queryEndTs - argTimeWindow; + ReadTsKvQuery query = buildTsRollingQuery(tenantId, argument, startInterval, queryEndTs); + + log.trace("[{}][{}] Fetching timeseries for query {}", tenantId, entityId, query); + ListenableFuture> tsRollingFuture = timeseriesService.findAll(tenantId, entityId, List.of(query)); + return Futures.transform(tsRollingFuture, tsRolling -> { + log.debug("[{}][{}] Fetched {} timeseries for query {}", tenantId, entityId, tsRolling == null ? 0 : tsRolling.size(), query); + return ArgumentEntry.createTsRollingArgument(tsRolling, query.getLimit(), argTimeWindow); + }, calculatedFieldCallbackExecutor); + } + + private ListenableFuture fetchAttribute(TenantId tenantId, EntityId entityId, Argument argument, long defaultLastUpdateTs) { + log.trace("[{}][{}] Fetching attribute for key {}", tenantId, entityId, argument.getRefEntityKey()); + var attributeOptFuture = attributesService.find(tenantId, entityId, argument.getRefEntityKey().getScope(), argument.getRefEntityKey().getKey()); + + 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)); + return transformSingleValueArgument(Optional.of(attributeKvEntry)); + }, calculatedFieldCallbackExecutor); + } + + protected ListenableFuture fetchTsLatest(TenantId tenantId, EntityId entityId, Argument argument, long startTs) { + String timeseriesKey = argument.getRefEntityKey().getKey(); + log.trace("[{}][{}] Fetching latest timeseries {}", tenantId, entityId, timeseriesKey); + return transformSingleValueArgument( + Futures.transform( + 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))); + }, calculatedFieldCallbackExecutor)); + } + + private ReadTsKvQuery buildTsRollingQuery(TenantId tenantId, Argument argument, long startTs, long endTs) { + long maxDataPoints = apiLimitService.getLimit( + tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg); + int argumentLimit = argument.getLimit(); + int limit = argumentLimit == 0 || argumentLimit > maxDataPoints ? (int) maxDataPoints : argumentLimit; + return new BaseReadTsKvQuery(argument.getRefEntityKey().getKey(), startTs, endTs, 0, limit, Aggregation.NONE); + } + +} 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 847caccaff..86ed174485 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 @@ -34,6 +34,8 @@ public interface CalculatedFieldProcessingService { ListenableFuture fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId); + Map fetchDynamicArgsFromDb(CalculatedFieldCtx ctx, EntityId entityId); + Map fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map arguments); void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult calculationResult, List cfIds, 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 49acf6917c..c779c27419 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 @@ -34,4 +34,8 @@ public final class CalculatedFieldResult { (result.isTextual() && result.asText().isEmpty()); } + public String toStringOrElseNull() { + return result == null ? null : result.toString(); + } + } 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 f510857d68..dfe30a0e55 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 @@ -30,6 +30,7 @@ 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; @@ -52,6 +53,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { private final CalculatedFieldService calculatedFieldService; private final TbelInvokeService tbelInvokeService; private final ApiLimitService apiLimitService; + private final RelationService relationService; private final ConcurrentMap calculatedFields = new ConcurrentHashMap<>(); private final ConcurrentMap> entityIdCalculatedFields = new ConcurrentHashMap<>(); @@ -111,7 +113,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { if (ctx == null) { CalculatedField calculatedField = getCalculatedField(calculatedFieldId); if (calculatedField != null) { - ctx = new CalculatedFieldCtx(calculatedField, tbelInvokeService, apiLimitService); + ctx = new CalculatedFieldCtx(calculatedField, tbelInvokeService, apiLimitService, relationService); calculatedFieldsCtx.put(calculatedFieldId, ctx); log.debug("[{}] Put calculated field ctx into cache: {}", calculatedFieldId, ctx); } 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 f2a6916751..17dca5dd64 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 @@ -15,46 +15,28 @@ */ package org.thingsboard.server.service.cf; -import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.math.NumberUtils; import org.springframework.stereotype.Service; -import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.actors.calculatedField.CalculatedFieldTelemetryMsg; 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.StringUtils; +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.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.Aggregation; -import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; -import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; -import org.thingsboard.server.common.data.kv.BasicTsKvEntry; -import org.thingsboard.server.common.data.kv.BooleanDataEntry; -import org.thingsboard.server.common.data.kv.DoubleDataEntry; -import org.thingsboard.server.common.data.kv.KvEntry; -import org.thingsboard.server.common.data.kv.ReadTsKvQuery; -import org.thingsboard.server.common.data.kv.StringDataEntry; -import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.msg.TbMsgType; -import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; 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; import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.usagerecord.ApiLimitService; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto; @@ -70,20 +52,12 @@ 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.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 java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Map.Entry; -import java.util.Optional; import java.util.UUID; -import java.util.concurrent.ExecutionException; -import java.util.stream.Collectors; import static org.thingsboard.server.common.data.DataConstants.SCOPE; import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto; @@ -91,76 +65,53 @@ import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto; @TbRuleEngineComponent @Service @Slf4j -@RequiredArgsConstructor -public class DefaultCalculatedFieldProcessingService implements CalculatedFieldProcessingService { +public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedFieldProcessingService implements CalculatedFieldProcessingService { - private final AttributesService attributesService; - private final TimeseriesService timeseriesService; private final TbClusterService clusterService; - private final ApiLimitService apiLimitService; private final PartitionService partitionService; - private ListeningExecutorService calculatedFieldCallbackExecutor; - - @PostConstruct - public void init() { - calculatedFieldCallbackExecutor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool( - Math.max(4, Runtime.getRuntime().availableProcessors()), "calculated-field-callback")); + public DefaultCalculatedFieldProcessingService(AttributesService attributesService, + TimeseriesService timeseriesService, + ApiLimitService apiLimitService, + RelationService relationService, + TbClusterService clusterService, + PartitionService partitionService) { + super(attributesService, timeseriesService, apiLimitService, relationService); + this.clusterService = clusterService; + this.partitionService = partitionService; } - @PreDestroy - public void stop() { - if (calculatedFieldCallbackExecutor != null) { - calculatedFieldCallbackExecutor.shutdownNow(); - } + @Override + protected String getExecutorNamePrefix() { + return "calculated-field-callback"; } @Override public ListenableFuture fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId) { - Map> argFutures = new HashMap<>(); - for (var entry : ctx.getArguments().entrySet()) { - var argEntityId = entry.getValue().getRefEntityId() != null ? entry.getValue().getRefEntityId() : entityId; - var argValueFuture = fetchKvEntry(ctx.getTenantId(), argEntityId, entry.getValue()); - argFutures.put(entry.getKey(), argValueFuture); + return super.fetchStateFromDb(ctx, entityId); + } + + @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 Futures.whenAllComplete(argFutures.values()).call(() -> { - var result = createStateByType(ctx); - result.updateState(ctx, argFutures.entrySet().stream() - .collect(Collectors.toMap( - Entry::getKey, // Keep the key as is - entry -> { - try { - // Resolve the future to get the value - return entry.getValue().get(); - } catch (ExecutionException | InterruptedException e) { - throw new RuntimeException("Error getting future result for key: " + entry.getKey(), e); - } - } - ))); - return result; - }, calculatedFieldCallbackExecutor); + 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()) { - var argEntityId = entry.getValue().getRefEntityId() != null ? entry.getValue().getRefEntityId() : entityId; - var argValueFuture = fetchKvEntry(tenantId, argEntityId, entry.getValue()); + if (entry.getValue().hasDynamicSource()) { + continue; + } + var argEntityId = resolveEntityId(entityId, entry.getValue()); + var argValueFuture = fetchArgumentValue(tenantId, argEntityId, entry.getValue(), System.currentTimeMillis()); argFutures.put(entry.getKey(), argValueFuture); } - return argFutures.entrySet().stream() - .collect(Collectors.toMap( - Entry::getKey, // Keep the key as is - entry -> { - try { - // Resolve the future to get the value - return entry.getValue().get(); - } catch (ExecutionException | InterruptedException e) { - throw new RuntimeException("Error getting future result for key: " + entry.getKey(), e); - } - } - )); + return resolveArgumentFutures(argFutures); } @Override @@ -169,7 +120,7 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP 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.getResult().toString()).build(); + 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) { @@ -241,69 +192,6 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP return builder.build(); } - private ListenableFuture fetchKvEntry(TenantId tenantId, EntityId entityId, Argument argument) { - return switch (argument.getRefEntityKey().getType()) { - case TS_ROLLING -> fetchTsRolling(tenantId, entityId, argument); - case ATTRIBUTE -> transformSingleValueArgument( - Futures.transform( - attributesService.find(tenantId, entityId, argument.getRefEntityKey().getScope(), argument.getRefEntityKey().getKey()), - result -> result.or(() -> Optional.of(new BaseAttributeKvEntry(createDefaultKvEntry(argument), System.currentTimeMillis(), 0L))), - calculatedFieldCallbackExecutor) - ); - case TS_LATEST -> transformSingleValueArgument( - Futures.transform( - timeseriesService.findLatest(tenantId, entityId, argument.getRefEntityKey().getKey()), - result -> result.or(() -> Optional.of(new BasicTsKvEntry(System.currentTimeMillis(), createDefaultKvEntry(argument), 0L))), - calculatedFieldCallbackExecutor)); - }; - } - - private ListenableFuture transformSingleValueArgument(ListenableFuture> kvEntryFuture) { - return Futures.transform(kvEntryFuture, kvEntry -> { - if (kvEntry.isPresent() && kvEntry.get().getValue() != null) { - return ArgumentEntry.createSingleValueArgument(kvEntry.get()); - } else { - return new SingleValueArgumentEntry(); - } - }, calculatedFieldCallbackExecutor); - } - - private ListenableFuture fetchTsRolling(TenantId tenantId, EntityId entityId, Argument argument) { - long currentTime = System.currentTimeMillis(); - long timeWindow = argument.getTimeWindow() == 0 ? System.currentTimeMillis() : argument.getTimeWindow(); - long startTs = currentTime - timeWindow; - long maxDataPoints = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg); - int argumentLimit = argument.getLimit(); - int limit = argumentLimit == 0 || argumentLimit > maxDataPoints ? (int) maxDataPoints : argument.getLimit(); - - ReadTsKvQuery query = new BaseReadTsKvQuery(argument.getRefEntityKey().getKey(), startTs, currentTime, 0, limit, Aggregation.NONE); - ListenableFuture> tsRollingFuture = timeseriesService.findAll(tenantId, entityId, List.of(query)); - - return Futures.transform(tsRollingFuture, tsRolling -> tsRolling == null ? new TsRollingArgumentEntry(limit, timeWindow) : ArgumentEntry.createTsRollingArgument(tsRolling, limit, timeWindow), calculatedFieldCallbackExecutor); - } - - private KvEntry createDefaultKvEntry(Argument argument) { - String key = argument.getRefEntityKey().getKey(); - String defaultValue = argument.getDefaultValue(); - if (StringUtils.isBlank(defaultValue)) { - return new StringDataEntry(key, null); - } - if (NumberUtils.isParsable(defaultValue)) { - return new DoubleDataEntry(key, Double.parseDouble(defaultValue)); - } - if ("true".equalsIgnoreCase(defaultValue) || "false".equalsIgnoreCase(defaultValue)) { - return new BooleanDataEntry(key, Boolean.parseBoolean(defaultValue)); - } - return new StringDataEntry(key, defaultValue); - } - - private CalculatedFieldState createStateByType(CalculatedFieldCtx ctx) { - return switch (ctx.getCfType()) { - case SIMPLE -> new SimpleCalculatedFieldState(ctx.getArgNames()); - case SCRIPT -> new ScriptCalculatedFieldState(ctx.getArgNames()); - }; - } - private static class TbCallbackWrapper implements TbQueueCallback { private final TbCallback callback; 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 83e10b8194..2d43883131 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 @@ -19,10 +19,13 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; 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.geofencing.GeofencingArgumentEntry; import java.util.List; +import java.util.Map; @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, @@ -31,7 +34,8 @@ import java.util.List; ) @JsonSubTypes({ @JsonSubTypes.Type(value = SingleValueArgumentEntry.class, name = "SINGLE_VALUE"), - @JsonSubTypes.Type(value = TsRollingArgumentEntry.class, name = "TS_ROLLING") + @JsonSubTypes.Type(value = TsRollingArgumentEntry.class, name = "TS_ROLLING"), + @JsonSubTypes.Type(value = GeofencingArgumentEntry.class, name = "GEOFENCING") }) public interface ArgumentEntry { @@ -58,4 +62,8 @@ public interface ArgumentEntry { return new TsRollingArgumentEntry(kvEntries, limit, timeWindow); } + static ArgumentEntry createGeofencingValueArgument(Map entityIdkvEntryMap) { + return new GeofencingArgumentEntry(entityIdkvEntryMap); + } + } 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 68f973c7c1..876bfa2a3f 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 + SINGLE_VALUE, TS_ROLLING, GEOFENCING } 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 e21d56b6d2..9a1d06cf24 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 @@ -25,8 +25,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArgumentProto; - @Data @AllArgsConstructor public abstract class BaseCalculatedFieldState implements CalculatedFieldState { @@ -95,19 +93,7 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState { } } - @Override - public void checkArgumentSize(String name, ArgumentEntry entry, CalculatedFieldCtx ctx) { - if (entry instanceof TsRollingArgumentEntry) { - return; - } - if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) { - if (ctx.getMaxSingleValueArgumentSize() > 0 && toSingleValueArgumentProto(name, singleValueArgumentEntry).getSerializedSize() > ctx.getMaxSingleValueArgumentSize()) { - throw new IllegalArgumentException("Single value size exceeds the maximum allowed limit. The argument will not be used for calculation."); - } - } - } - - protected abstract void validateNewEntry(ArgumentEntry newEntry); + protected void validateNewEntry(ArgumentEntry newEntry) {} private void updateLastUpdateTimestamp(ArgumentEntry entry) { long newTs = this.latestTimestamp; 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 2e3321eece..c2cc083853 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 @@ -25,10 +25,13 @@ 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.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.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.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; @@ -36,6 +39,7 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; 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; @@ -64,6 +68,7 @@ public class CalculatedFieldCtx { private String expression; private boolean useLatestTs; private TbelInvokeService tbelInvokeService; + private RelationService relationService; private CalculatedFieldScriptEngine calculatedFieldScriptEngine; private ThreadLocal customExpression; @@ -73,31 +78,56 @@ public class CalculatedFieldCtx { private long maxStateSize; private long maxSingleValueArgumentSize; - public CalculatedFieldCtx(CalculatedField calculatedField, TbelInvokeService tbelInvokeService, ApiLimitService apiLimitService) { + private List mainEntityGeofencingArgumentNames; + private List linkedEntityGeofencingArgumentNames; + + public CalculatedFieldCtx(CalculatedField calculatedField, TbelInvokeService tbelInvokeService, ApiLimitService apiLimitService, RelationService relationService) { this.calculatedField = calculatedField; this.cfId = calculatedField.getId(); this.tenantId = calculatedField.getTenantId(); this.entityId = calculatedField.getEntityId(); this.cfType = calculatedField.getType(); - CalculatedFieldConfiguration configuration = calculatedField.getConfiguration(); - this.arguments = configuration.getArguments(); + this.arguments = new HashMap<>(); this.mainEntityArguments = new HashMap<>(); this.linkedEntityArguments = new HashMap<>(); - for (Map.Entry entry : arguments.entrySet()) { - var refId = entry.getValue().getRefEntityId(); - var refKey = entry.getValue().getRefEntityKey(); - if (refId == null || refId.equals(calculatedField.getEntityId())) { - mainEntityArguments.put(refKey, entry.getKey()); - } else { - linkedEntityArguments.computeIfAbsent(refId, key -> new HashMap<>()).put(refKey, entry.getKey()); + this.argNames = new ArrayList<>(); + this.mainEntityGeofencingArgumentNames = new ArrayList<>(); + this.linkedEntityGeofencingArgumentNames = 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()); + } else { + linkedEntityArguments.computeIfAbsent(refId, key -> new HashMap<>()).put(refKey, entry.getKey()); + } + } + this.argNames.addAll(arguments.keySet()); + if (argBasedConfig instanceof ExpressionBasedCalculatedFieldConfiguration expressionBasedConfig) { + this.expression = expressionBasedConfig.getExpression(); + this.useLatestTs = CalculatedFieldType.SIMPLE.equals(calculatedField.getType()) && ((SimpleCalculatedFieldConfiguration) argBasedConfig).isUseLatestTs(); + } + if (calculatedField.getConfiguration() instanceof GeofencingCalculatedFieldConfiguration geofencingConfig) { + geofencingConfig.getZoneGroups().forEach((zoneGroupName, config) -> { + if (config.isCfEntitySource(entityId)) { + mainEntityGeofencingArgumentNames.add(zoneGroupName); + return; + } + if (config.isLinkedCfEntitySource(entityId)) { + linkedEntityGeofencingArgumentNames.add(zoneGroupName); + } + }); } } - this.argNames = new ArrayList<>(arguments.keySet()); - this.output = configuration.getOutput(); - this.expression = configuration.getExpression(); - this.useLatestTs = CalculatedFieldType.SIMPLE.equals(calculatedField.getType()) && ((SimpleCalculatedFieldConfiguration) configuration).isUseLatestTs(); this.tbelInvokeService = tbelInvokeService; + this.relationService = relationService; this.maxDataPointsPerRollingArg = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg); this.maxStateSize = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxStateSizeInKBytes) * 1024; @@ -105,25 +135,29 @@ public class CalculatedFieldCtx { } public void init() { - if (CalculatedFieldType.SCRIPT.equals(cfType)) { - 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); + 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); + } } - } else { - 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."); + 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."); + } } } } @@ -293,7 +327,7 @@ public class CalculatedFieldCtx { } public boolean hasOtherSignificantChanges(CalculatedFieldCtx other) { - boolean expressionChanged = !expression.equals(other.expression); + boolean expressionChanged = calculatedField.getConfiguration() instanceof ExpressionBasedCalculatedFieldConfiguration && !expression.equals(other.expression); boolean outputChanged = !output.equals(other.output); return expressionChanged || outputChanged; } @@ -304,6 +338,16 @@ public class CalculatedFieldCtx { return typeChanged || argumentsChanged; } + 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; + } + return false; + } + public String getSizeExceedsLimitMessage() { return "Failed to init CF state. State size exceeds limit of " + (maxStateSize / 1024) + "Kb!"; } 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 0de354bbb0..5f8e7538c4 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 @@ -20,12 +20,17 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.service.cf.CalculatedFieldResult; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; 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, @@ -34,6 +39,7 @@ import java.util.Map; @JsonSubTypes({ @JsonSubTypes.Type(value = SimpleCalculatedFieldState.class, name = "SIMPLE"), @JsonSubTypes.Type(value = ScriptCalculatedFieldState.class, name = "SCRIPT"), + @JsonSubTypes.Type(value = GeofencingCalculatedFieldState.class, name = "GEOFENCING"), }) public interface CalculatedFieldState { @@ -44,11 +50,18 @@ public interface CalculatedFieldState { long getLatestTimestamp(); + default void setDirty(boolean dirty) { + } + + default boolean isDirty() { + return false; + } + void setRequiredArguments(List requiredArguments); boolean updateState(CalculatedFieldCtx ctx, Map argumentValues); - ListenableFuture performCalculation(CalculatedFieldCtx ctx); + ListenableFuture performCalculation(EntityId entityId, CalculatedFieldCtx ctx); @JsonIgnore boolean isReady(); @@ -62,6 +75,15 @@ public interface CalculatedFieldState { void checkStateSize(CalculatedFieldEntityCtxId ctxId, long maxStateSize); - void checkArgumentSize(String name, ArgumentEntry entry, CalculatedFieldCtx ctx); + default void checkArgumentSize(String name, ArgumentEntry entry, CalculatedFieldCtx ctx) { + if (entry instanceof TsRollingArgumentEntry || entry instanceof GeofencingArgumentEntry) { + return; + } + if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) { + if (ctx.getMaxSingleValueArgumentSize() > 0 && toSingleValueArgumentProto(name, singleValueArgumentEntry).getSerializedSize() > ctx.getMaxSingleValueArgumentSize()) { + throw new IllegalArgumentException("Single value size exceeds the maximum allowed limit. The argument will not be used for calculation."); + } + } + } } 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 84dce627ae..fe7dfa04d0 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 @@ -20,6 +20,7 @@ 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; @@ -27,6 +28,7 @@ import org.thingsboard.script.api.tbel.TbelCfCtx; import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; 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 java.util.ArrayList; @@ -37,6 +39,7 @@ import java.util.Map; @Data @Slf4j @NoArgsConstructor +@EqualsAndHashCode(callSuper = true) public class ScriptCalculatedFieldState extends BaseCalculatedFieldState { public ScriptCalculatedFieldState(List requiredArguments) { @@ -49,11 +52,7 @@ public class ScriptCalculatedFieldState extends BaseCalculatedFieldState { } @Override - protected void validateNewEntry(ArgumentEntry newEntry) { - } - - @Override - public ListenableFuture performCalculation(CalculatedFieldCtx ctx) { + 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; 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 577ff80219..80b650fc7c 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 @@ -20,11 +20,13 @@ 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 org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.tbel.TbUtils; 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; @@ -33,6 +35,7 @@ import java.util.Map; @Data @NoArgsConstructor +@EqualsAndHashCode(callSuper = true) public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { public SimpleCalculatedFieldState(List requiredArguments) { @@ -52,7 +55,7 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { } @Override - public ListenableFuture performCalculation(CalculatedFieldCtx ctx) { + public ListenableFuture performCalculation(EntityId entityId, CalculatedFieldCtx ctx) { var expr = ctx.getCustomExpression().get(); for (Map.Entry entry : this.arguments.entrySet()) { 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 new file mode 100644 index 0000000000..f526cc00ab --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java @@ -0,0 +1,103 @@ +/** + * 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.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.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.util.ProtoUtils; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType; + +import java.util.Map; +import java.util.stream.Collectors; + +@Data +@Slf4j +public class GeofencingArgumentEntry implements ArgumentEntry { + + private Map zoneStates; + + private boolean forceResetPrevious; + + public GeofencingArgumentEntry() { + } + + public GeofencingArgumentEntry(EntityId entityId, TransportProtos.AttributeValueProto entry) { + this.zoneStates = toZones(Map.of(entityId, ProtoUtils.fromProto(entry))); + } + + public GeofencingArgumentEntry(Map entityIdkvEntryMap) { + this.zoneStates = toZones(entityIdkvEntryMap); + } + + @Override + public ArgumentEntryType getType() { + return ArgumentEntryType.GEOFENCING; + } + + @Override + public Object getValue() { + return zoneStates; + } + + @Override + public boolean updateEntry(ArgumentEntry entry) { + if (!(entry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) { + throw new IllegalArgumentException("Unsupported argument entry type for geofencing argument entry: " + entry.getType()); + } + boolean updated = false; + for (var zoneEntry : geofencingArgumentEntry.getZoneStates().entrySet()) { + if (updateZone(zoneEntry)) { + updated = true; + } + } + return updated; + } + + @Override + public boolean isEmpty() { + return zoneStates == null || zoneStates.isEmpty(); + } + + @Override + public TbelCfArg toTbelCfArg() { + return new TbelCfTsGeofencingArg(zoneStates); + } + + private Map toZones(Map entityIdKvEntryMap) { + return entityIdKvEntryMap.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, + entry -> new GeofencingZoneState(entry.getKey(), entry.getValue()))); + } + + private boolean updateZone(Map.Entry zoneEntry) { + EntityId zoneId = zoneEntry.getKey(); + GeofencingZoneState newZoneState = zoneEntry.getValue(); + + GeofencingZoneState existingZoneState = zoneStates.get(zoneId); + if (existingZoneState == null) { + zoneStates.put(zoneId, newZoneState); + 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 new file mode 100644 index 0000000000..506ddcff78 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java @@ -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. + */ +package org.thingsboard.server.service.cf.ctx.state.geofencing; + +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.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.geofencing.GeofencingCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy; +import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent; +import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; +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.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType; +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.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +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.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.INSIDE; +import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.OUTSIDE; + +@Data +@Slf4j +@EqualsAndHashCode(callSuper = true) +public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { + + private boolean dirty; + + public GeofencingCalculatedFieldState() { + super(new ArrayList<>(), new HashMap<>(), false, -1); + this.dirty = false; + } + + public GeofencingCalculatedFieldState(List argNames) { + super(argNames); + } + + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.GEOFENCING; + } + + @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); + } + if (entryUpdated) { + stateUpdated = true; + } + } + return stateUpdated; + } + + @Override + public ListenableFuture performCalculation(EntityId entityId, 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); + + var geofencingCfg = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); + Map zoneGroups = geofencingCfg.getZoneGroups(); + + ObjectNode resultNode = JacksonUtil.newObjectNode(); + List> relationFutures = new ArrayList<>(); + + getGeofencingArguments().forEach((argumentKey, argumentEntry) -> { + ZoneGroupConfiguration zoneGroupCfg = zoneGroups.get(argumentKey); + if (zoneGroupCfg == null) { + throw new RuntimeException("Zone group configuration is missing for the: " + entityId); + } + boolean createRelationsWithMatchedZones = zoneGroupCfg.isCreateRelationsWithMatchedZones(); + List zoneResults = new ArrayList<>(argumentEntry.getZoneStates().size()); + argumentEntry.getZoneStates().forEach((zoneId, zoneState) -> { + GeofencingEvalResult eval = zoneState.evaluate(entityCoordinates); + zoneResults.add(eval); + if (createRelationsWithMatchedZones) { + GeofencingTransitionEvent transitionEvent = eval.transition(); + if (transitionEvent == null) { + return; + } + EntityRelation relation = switch (zoneGroupCfg.getDirection()) { + case TO -> new EntityRelation(zoneId, entityId, zoneGroupCfg.getRelationType()); + case FROM -> new EntityRelation(entityId, zoneId, zoneGroupCfg.getRelationType()); + }; + ListenableFuture f = switch (transitionEvent) { + case ENTERED -> ctx.getRelationService().saveRelationAsync(ctx.getTenantId(), relation); + case LEFT -> ctx.getRelationService().deleteRelationAsync(ctx.getTenantId(), relation); + }; + relationFutures.add(f); + } + }); + updateResultNode(argumentKey, zoneResults, zoneGroupCfg.getReportStrategy(), resultNode); + }); + + var result = new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), resultNode); + if (relationFutures.isEmpty()) { + return Futures.immediateFuture(result); + } + return Futures.whenAllComplete(relationFutures).call(() -> result, MoreExecutors.directExecutor()); + } + + private Map getGeofencingArguments() { + return arguments.entrySet() + .stream() + .filter(entry -> entry.getValue().getType().equals(ArgumentEntryType.GEOFENCING)) + .collect(Collectors.toMap(Map.Entry::getKey, entry -> (GeofencingArgumentEntry) entry.getValue())); + } + + private void updateResultNode(String argumentKey, List zoneResults, GeofencingReportStrategy geofencingReportStrategy, ObjectNode resultNode) { + GeofencingEvalResult aggregationResult = aggregateZoneGroup(zoneResults); + final String eventKey = argumentKey + "Event"; + final String statusKey = argumentKey + "Status"; + switch (geofencingReportStrategy) { + case REPORT_TRANSITION_EVENTS_ONLY -> addTransitionEventIfExists(resultNode, aggregationResult, eventKey); + case REPORT_PRESENCE_STATUS_ONLY -> resultNode.put(statusKey, aggregationResult.status().name()); + case REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS -> { + addTransitionEventIfExists(resultNode, aggregationResult, eventKey); + resultNode.put(statusKey, aggregationResult.status().name()); + } + } + } + + private GeofencingEvalResult aggregateZoneGroup(List zoneResults) { + boolean nowInside = zoneResults.stream().anyMatch(r -> INSIDE.equals(r.status())); + boolean prevInside = zoneResults.stream() + .anyMatch(r -> GeofencingTransitionEvent.LEFT.equals(r.transition()) || r.transition() == null && r.status() == INSIDE); + GeofencingTransitionEvent transition = null; + if (!prevInside && nowInside) { + transition = GeofencingTransitionEvent.ENTERED; + } else if (prevInside && !nowInside) { + transition = GeofencingTransitionEvent.LEFT; + } + return new GeofencingEvalResult(transition, nowInside ? INSIDE : OUTSIDE); + } + + private void addTransitionEventIfExists(ObjectNode resultNode, GeofencingEvalResult aggregationResult, String eventKey) { + if (aggregationResult.transition() != null) { + resultNode.put(eventKey, aggregationResult.transition().name()); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingEvalResult.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingEvalResult.java new file mode 100644 index 0000000000..c6bf3dd65e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingEvalResult.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.cf.ctx.state.geofencing; + +import jakarta.annotation.Nullable; +import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus; +import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent; + +public record GeofencingEvalResult(@Nullable GeofencingTransitionEvent transition, + GeofencingPresenceStatus status) { +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java new file mode 100644 index 0000000000..c849f5d169 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java @@ -0,0 +1,106 @@ +/** + * 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.geofencing; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.geo.Coordinates; +import org.thingsboard.common.util.geo.PerimeterDefinition; +import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus; +import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent; +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.util.ProtoUtils; +import org.thingsboard.server.gen.transport.TransportProtos.GeofencingZoneProto; + +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 +public class GeofencingZoneState { + + private final EntityId zoneId; + + private long ts; + private Long version; + private PerimeterDefinition perimeterDefinition; + + @EqualsAndHashCode.Exclude + private GeofencingPresenceStatus lastPresence; + + public GeofencingZoneState(EntityId zoneId, KvEntry entry) { + this.zoneId = zoneId; + if (!(entry instanceof AttributeKvEntry attributeKvEntry)) { + throw new IllegalArgumentException("Unsupported KvEntry type for geofencing zone state: " + entry.getClass().getSimpleName()); + } + this.ts = attributeKvEntry.getLastUpdateTs(); + this.version = attributeKvEntry.getVersion(); + this.perimeterDefinition = JacksonUtil.fromString(entry.getValueAsString(), PerimeterDefinition.class); + } + + public GeofencingZoneState(GeofencingZoneProto proto) { + this.zoneId = ProtoUtils.fromProto(proto.getZoneId()); + this.ts = proto.getTs(); + this.version = proto.getVersion(); + this.perimeterDefinition = JacksonUtil.fromString(proto.getPerimeterDefinition(), PerimeterDefinition.class); + if (proto.hasInside()) { + this.lastPresence = proto.getInside() ? INSIDE : OUTSIDE; + } + } + + public boolean update(GeofencingZoneState newZoneState) { + if (newZoneState.getTs() <= this.ts) { + return false; + } + Long newVersion = newZoneState.getVersion(); + if (newVersion == null || this.version == null || newVersion > this.version) { + this.ts = newZoneState.getTs(); + this.version = newVersion; + this.perimeterDefinition = newZoneState.getPerimeterDefinition(); + this.lastPresence = null; + return true; + } + return false; + } + + public GeofencingEvalResult evaluate(Coordinates entityCoordinates) { + boolean nowInside = perimeterDefinition.checkMatches(entityCoordinates); + + GeofencingPresenceStatus status = nowInside ? INSIDE : OUTSIDE; + + // first evaluation + if (this.lastPresence == null) { + this.lastPresence = status; + GeofencingTransitionEvent transition = null; + if (status == GeofencingPresenceStatus.INSIDE) { + transition = GeofencingTransitionEvent.ENTERED; + } + return new GeofencingEvalResult(transition, status); + } + // State changed + if (this.lastPresence != status) { + this.lastPresence = status; + GeofencingTransitionEvent transition = (status == GeofencingPresenceStatus.INSIDE) ? + GeofencingTransitionEvent.ENTERED : GeofencingTransitionEvent.LEFT; + return new GeofencingEvalResult(transition, status); + } + // State unchanged + return new GeofencingEvalResult(null, status); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeEventStorageSettings.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeEventStorageSettings.java index 89d19438bd..618aee5e00 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeEventStorageSettings.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeEventStorageSettings.java @@ -29,4 +29,6 @@ public class EdgeEventStorageSettings { private long noRecordsSleepInterval; @Value("${edges.storage.sleep_between_batches}") private long sleepIntervalBetweenBatches; + @Value("${edges.storage.misordering_compensation_millis:60000}") + private long misorderingCompensationMillis; } 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 521730741f..a86409c6af 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 @@ -589,7 +589,8 @@ public abstract class EdgeGrpcSession implements Closeable { previousStartSeqId, false, Integer.toUnsignedLong(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount()), - ctx.getEdgeEventService()); + ctx.getEdgeEventService(), + ctx.getEdgeEventStorageSettings().getMisorderingCompensationMillis()); log.trace("[{}][{}] starting processing edge events, previousStartTs = {}, previousStartSeqId = {}", tenantId, edge.getId(), previousStartTs, previousStartSeqId); Futures.addCallback(startProcessingEdgeEvents(fetcher), new FutureCallback<>() { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/GeneralEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/GeneralEdgeEventFetcher.java index 5d7df601b5..b4f894c422 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/GeneralEdgeEventFetcher.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/GeneralEdgeEventFetcher.java @@ -26,13 +26,9 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.dao.edge.EdgeEventService; -import java.util.concurrent.TimeUnit; - @AllArgsConstructor @Slf4j public class GeneralEdgeEventFetcher implements EdgeEventFetcher { - // Subtract from queueStartTs to ensure no data is lost due to potential misordering of edge events by created_time. - private static final long MISORDERING_COMPENSATION_MILLIS = TimeUnit.SECONDS.toMillis(60); private final Long queueStartTs; private Long seqIdStart; @@ -40,6 +36,10 @@ public class GeneralEdgeEventFetcher implements EdgeEventFetcher { private boolean seqIdNewCycleStarted; private Long maxReadRecordsCount; private final EdgeEventService edgeEventService; + // Subtract from queueStartTs to compensate for possible misalignment between `created_time` and `seqId`. + // This ensures early events with lower seqId are not skipped due to partitioning by `created_time`. + // See: edge_event is partitioned by created_time but sorted by seqId during retrieval. + private final long misorderingCompensationMillis; @Override public PageLink getPageLink(int pageSize) { @@ -48,7 +48,7 @@ public class GeneralEdgeEventFetcher implements EdgeEventFetcher { 0, null, null, - queueStartTs > 0 ? queueStartTs - MISORDERING_COMPENSATION_MILLIS : 0, + queueStartTs > 0 ? queueStartTs - misorderingCompensationMillis : 0, System.currentTimeMillis()); } 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 5c2fcc7dc6..b7b027d785 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 @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.EntityType; 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.exception.ThingsboardException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; @@ -153,11 +154,13 @@ public class DefaultEntityExportService calculatedFields = calculatedFieldService.findCalculatedFieldsByEntityId(ctx.getTenantId(), entityId); calculatedFields.forEach(calculatedField -> { calculatedField.setEntityId(getExternalIdOrElseInternal(ctx, entityId)); - calculatedField.getConfiguration().getArguments().values().forEach(argument -> { - if (argument.getRefEntityId() != null) { - argument.setRefEntityId(getExternalIdOrElseInternal(ctx, argument.getRefEntityId())); - } - }); + if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration configuration) { + configuration.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/impl/BaseEntityImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java index 92fdcb09c4..2b606a2fe8 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 @@ -35,6 +35,7 @@ import org.thingsboard.server.common.data.HasVersion; 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.exception.ThingsboardException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; @@ -321,11 +322,13 @@ public abstract class BaseEntityImportService { calculatedField.setTenantId(ctx.getTenantId()); calculatedField.setEntityId(savedEntity.getId()); - calculatedField.getConfiguration().getArguments().values().forEach(argument -> { - if (argument.getRefEntityId() != null) { - argument.setRefEntityId(idProvider.getInternalId(argument.getRefEntityId(), ctx.isFinalImportAttempt())); - } - }); + if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration configuration) { + configuration.getArguments().values().forEach(argument -> { + if (argument.getRefEntityId() != null) { + argument.setRefEntityId(idProvider.getInternalId(argument.getRefEntityId(), ctx.isFinalImportAttempt())); + } + }); + } }).toList(); for (CalculatedField existingField : existing) { diff --git a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java new file mode 100644 index 0000000000..934eadd98f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java @@ -0,0 +1,75 @@ +/** + * 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.utils; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +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.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.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 java.util.Optional; + +public class CalculatedFieldArgumentUtils { + + public static ListenableFuture transformSingleValueArgument(ListenableFuture> kvEntryFuture) { + return Futures.transform(kvEntryFuture, CalculatedFieldArgumentUtils::transformSingleValueArgument, MoreExecutors.directExecutor()); + } + + public static ArgumentEntry transformSingleValueArgument(Optional kvEntry) { + if (kvEntry.isPresent() && kvEntry.get().getValue() != null) { + return ArgumentEntry.createSingleValueArgument(kvEntry.get()); + } else { + return new SingleValueArgumentEntry(); + } + } + + public static KvEntry createDefaultKvEntry(Argument argument) { + String key = argument.getRefEntityKey().getKey(); + String defaultValue = argument.getDefaultValue(); + if (StringUtils.isBlank(defaultValue)) { + return new StringDataEntry(key, null); + } + if (NumberUtils.isParsable(defaultValue)) { + return new DoubleDataEntry(key, Double.parseDouble(defaultValue)); + } + if ("true".equalsIgnoreCase(defaultValue) || "false".equalsIgnoreCase(defaultValue)) { + return new BooleanDataEntry(key, Boolean.parseBoolean(defaultValue)); + } + return new StringDataEntry(key, defaultValue); + } + + public static CalculatedFieldState createStateByType(CalculatedFieldCtx ctx) { + return switch (ctx.getCfType()) { + case SIMPLE -> new SimpleCalculatedFieldState(ctx.getArgNames()); + case SCRIPT -> new ScriptCalculatedFieldState(ctx.getArgNames()); + case GEOFENCING -> new GeofencingCalculatedFieldState(ctx.getArgNames()); + }; + } + +} 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 77080c28c8..4e93c8233e 100644 --- a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java +++ b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java @@ -15,31 +15,43 @@ */ package org.thingsboard.server.utils; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.StringUtils; 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; 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.kv.BasicKvEntry; import org.thingsboard.server.common.util.KvProtoUtil; +import org.thingsboard.server.common.util.ProtoUtils; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldEntityCtxIdProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldIdProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; +import org.thingsboard.server.gen.transport.TransportProtos.GeofencingArgumentProto; +import org.thingsboard.server.gen.transport.TransportProtos.GeofencingZoneProto; import org.thingsboard.server.gen.transport.TransportProtos.SingleValueArgumentProto; import org.thingsboard.server.gen.transport.TransportProtos.TsDoubleValProto; import org.thingsboard.server.gen.transport.TransportProtos.TsRollingArgumentProto; 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 java.util.Map; import java.util.Optional; import java.util.TreeMap; import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; public class CalculatedFieldUtils { @@ -79,6 +91,8 @@ public class CalculatedFieldUtils { 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)); } }); return builder.build(); @@ -108,6 +122,27 @@ public class CalculatedFieldUtils { return builder.build(); } + private static GeofencingArgumentProto toGeofencingArgumentProto(String argName, GeofencingArgumentEntry geofencingArgumentEntry) { + Map zoneStates = geofencingArgumentEntry.getZoneStates(); + GeofencingArgumentProto.Builder builder = GeofencingArgumentProto.newBuilder() + .setArgName(argName); + zoneStates.forEach((entityId, zoneState) -> + builder.addZones(toGeofencingZoneProto(entityId, zoneState))); + return builder.build(); + } + + private static GeofencingZoneProto toGeofencingZoneProto(EntityId entityId, GeofencingZoneState zoneState) { + GeofencingZoneProto.Builder builder = GeofencingZoneProto.newBuilder() + .setZoneId(ProtoUtils.toProto(entityId)) + .setTs(zoneState.getTs()) + .setVersion(zoneState.getVersion()) + .setPerimeterDefinition(JacksonUtil.toString(zoneState.getPerimeterDefinition())); + if (zoneState.getLastPresence() != null) { + builder.setInside(zoneState.getLastPresence().equals(GeofencingPresenceStatus.INSIDE)); + } + return builder.build(); + } + public static CalculatedFieldState fromProto(CalculatedFieldStateProto proto) { if (StringUtils.isEmpty(proto.getType())) { return null; @@ -118,6 +153,7 @@ public class CalculatedFieldUtils { CalculatedFieldState state = switch (type) { case SIMPLE -> new SimpleCalculatedFieldState(); case SCRIPT -> new ScriptCalculatedFieldState(); + case GEOFENCING -> new GeofencingCalculatedFieldState(); }; proto.getSingleValueArgumentsList().forEach(argProto -> @@ -128,6 +164,11 @@ public class CalculatedFieldUtils { state.getArguments().put(argProto.getKey(), fromRollingArgumentProto(argProto))); } + if (CalculatedFieldType.GEOFENCING.equals(type)) { + proto.getGeofencingArgumentsList().forEach(argProto -> + state.getArguments().put(argProto.getArgName(), fromGeofencingArgumentProto(argProto))); + } + return state; } @@ -149,4 +190,15 @@ public class CalculatedFieldUtils { return new TsRollingArgumentEntry(tsRecords, proto.getLimit(), proto.getTimeWindow()); } + + private static ArgumentEntry fromGeofencingArgumentProto(GeofencingArgumentProto proto) { + Map zoneStates = proto.getZonesList() + .stream() + .map(GeofencingZoneState::new) + .collect(Collectors.toMap(GeofencingZoneState::getZoneId, Function.identity())); + GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry(); + geofencingArgumentEntry.setZoneStates(zoneStates); + return geofencingArgumentEntry; + } + } diff --git a/application/src/main/resources/logback.xml b/application/src/main/resources/logback.xml index c37be2e620..8e1a49faef 100644 --- a/application/src/main/resources/logback.xml +++ b/application/src/main/resources/logback.xml @@ -56,6 +56,9 @@ + + + diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 89be36f470..68cd479411 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1483,6 +1483,11 @@ edges: no_read_records_sleep: "${EDGES_NO_READ_RECORDS_SLEEP:1000}" # Number of milliseconds to wait before resending failed batch of edge events to edge sleep_between_batches: "${EDGES_SLEEP_BETWEEN_BATCHES:60000}" + # Time (in milliseconds) to subtract from the start timestamp when fetching edge events. + # This compensates for possible misordering between `created_time` (used for partitioning) + # and `seqId` (used for sorting). Without this, events with smaller seqId but larger created_time + # might be skipped, especially across partition boundaries. + misordering_compensation_millis: "${EDGES_MISORDERING_COMPENSATION_MILLIS:60000}" # Max number of high priority edge events per edge session. No persistence - stored in memory max_high_priority_queue_size_per_session: "${EDGES_MAX_HIGH_PRIORITY_QUEUE_SIZE_PER_SESSION:10000}" # Number of threads that are used to check DB for edge events @@ -1537,6 +1542,8 @@ 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) + doc_expansion: "${SWAGGER_DOC_EXPANSION:list}" # Queue configuration parameters queue: 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 c8b8b0244b..b6a1faa1ed 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.cf; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Test; @@ -23,28 +24,42 @@ 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.TenantProfile; 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.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.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; 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; +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.debug.DebugSettings; 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.controller.CalculatedFieldControllerTest; 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.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.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS; @DaoSqlTest public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTest { @@ -121,7 +136,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(temperatureF.get(0).get("value").asText()).isEqualTo("86.0"); }); - Argument savedArgument = savedCalculatedField.getConfiguration().getArguments().get("T"); + Argument savedArgument = ((SimpleCalculatedFieldConfiguration) savedCalculatedField.getConfiguration()).getArguments().get("T"); savedArgument.setRefEntityKey(new ReferencedEntityKey("deviceTemperature", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); savedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); @@ -133,7 +148,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(temperatureF.get(0).get("value").asText()).isEqualTo("104.0"); }); - savedCalculatedField.getConfiguration().setExpression("1.8 * T + 32"); + ((SimpleCalculatedFieldConfiguration) savedCalculatedField.getConfiguration()).setExpression("1.8 * T + 32"); savedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); await().alias("update CF expression -> perform calculation with new expression").atMost(TIMEOUT, TimeUnit.SECONDS) @@ -606,6 +621,339 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes }); } + @Test + public void testGeofencingCalculatedField_withZonesCreatedOnDevice() throws Exception { + // --- Arrange entities --- + Device device = createDevice("GF Test Device", "sn-geo-2"); + + // Allowed zone polygon (square) + String allowedPolygon = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"; + // Restricted zone polygon (square) + String restrictedPolygon = "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]"; + + doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, + JacksonUtil.toJsonNode("{\"allowedZone\":" + allowedPolygon + "}")).andExpect(status().isOk()); + + doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, + JacksonUtil.toJsonNode("{\"restrictedZone\":" + restrictedPolygon + "}")).andExpect(status().isOk()); + + // Initial device coordinates (inside Allowed, outside Restricted) + doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope", + JacksonUtil.toJsonNode("{\"latitude\":50.4730,\"longitude\":30.5050}")); + + // --- Build CF: GEOFENCING --- + CalculatedField cf = new CalculatedField(); + cf.setEntityId(device.getDeviceProfileId()); + cf.setType(CalculatedFieldType.GEOFENCING); + cf.setName("Geofencing CF"); + cf.setDebugSettings(DebugSettings.off()); + + GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration(); + + // Coordinates: TS_LATEST on the device + EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude"); + cfg.setEntityCoordinates(entityCoordinates); + + // Zone groups: ATTRIBUTE on the device + ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("allowedZone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("restrictedZone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + + cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup)); + + // Output to server attributes + Output out = new Output(); + out.setType(OutputType.ATTRIBUTES); + out.setScope(AttributeScope.SERVER_SCOPE); + cfg.setOutput(out); + + cf.setConfiguration(cfg); + + doPost("/api/calculatedField", cf, CalculatedField.class); + + // --- Assert initial evaluation (ENTERED / OUTSIDE) --- + await().alias("initial geofencing evaluation") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs = getServerAttributes(device.getId(), + "allowedZonesEvent", "allowedZonesStatus", "restrictedZonesStatus", "restrictedZonesEvent"); + // --- no restrictedZonesEvent as no transition happened yet + assertThat(attrs).isNotNull().isNotEmpty().hasSize(3); + Map m = kv(attrs); + assertThat(m).containsEntry("allowedZonesEvent", "ENTERED") + .containsEntry("allowedZonesStatus", "INSIDE") + .containsEntry("restrictedZonesStatus", "OUTSIDE"); + }); + + // --- delete attributes reported in previous evaluation + doDelete("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/SERVER_SCOPE?keys=allowedZonesEvent,allowedZonesStatus,restrictedZonesStatus", String.class); + + // --- Update restrictedZone by 'restrictedZone' attribute update + doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, + JacksonUtil.toJsonNode("{\"restrictedZone\":" + restrictedPolygon + "}")).andExpect(status().isOk()); + + // --- Assert no transition --- + // --- Assert attributes updated with the same values for restrictedZones --- + // --- Assert attributes updated with the new values for allowedZones --- + await().alias("evaluation after version bump of geo argument") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs = getServerAttributes(device.getId(), + "allowedZonesEvent", "allowedZonesStatus", + "restrictedZonesEvent", "restrictedZonesStatus"); + assertThat(attrs).isNotNull().isNotEmpty().hasSize(2); + Map m = kv(attrs); + assertThat(m).containsEntry("allowedZonesStatus", "INSIDE") + .containsEntry("restrictedZonesStatus", "OUTSIDE"); + }); + } + + @Test + public void testGeofencingCalculatedField_withoutRelationsCreationAndDynamicRefresh() throws Exception { + // --- Arrange entities --- + Device device = createDevice("GF Device", "sn-geo-1"); + + // Allowed zone polygon (square) + String allowedPolygon = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"; + // Restricted zone polygon (square) + String restrictedPolygon = "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]"; + + Asset allowedZoneAsset = createAsset("Allowed Zone", null); + doPost("/api/plugins/telemetry/ASSET/" + allowedZoneAsset.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, + JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygon + "}")).andExpect(status().isOk()); + + Asset restrictedZoneAsset = createAsset("Restricted Zone", null); + doPost("/api/plugins/telemetry/ASSET/" + restrictedZoneAsset.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, + JacksonUtil.toJsonNode("{\"zone\":" + restrictedPolygon + "}")).andExpect(status().isOk()); + + // Relations from device to zones + EntityRelation deviceToAllowedZoneRelation = new EntityRelation(); + deviceToAllowedZoneRelation.setFrom(device.getId()); + deviceToAllowedZoneRelation.setTo(allowedZoneAsset.getId()); + deviceToAllowedZoneRelation.setType("AllowedZone"); + + EntityRelation deviceToRestrictedZoneRelation = new EntityRelation(); + deviceToRestrictedZoneRelation.setFrom(device.getId()); + deviceToRestrictedZoneRelation.setTo(restrictedZoneAsset.getId()); + deviceToRestrictedZoneRelation.setType("RestrictedZone"); + + doPost("/api/relation", deviceToAllowedZoneRelation).andExpect(status().isOk()); + doPost("/api/relation", deviceToRestrictedZoneRelation).andExpect(status().isOk()); + + // Initial device coordinates (inside Allowed, outside Restricted) + doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope", + JacksonUtil.toJsonNode("{\"latitude\":50.4730,\"longitude\":30.5050}")); + + // --- Build CF: GEOFENCING --- + CalculatedField cf = new CalculatedField(); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.GEOFENCING); + cf.setName("Geofencing CF"); + cf.setDebugSettings(DebugSettings.off()); + + GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration(); + + // Coordinates: TS_LATEST on the device + EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude"); + cfg.setEntityCoordinates(entityCoordinates); + + // 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); + 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); + restrictedZonesGroup.setRefDynamicSourceConfiguration(restrictedZoneDynamicSourceConfiguration); + + cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup)); + + // Output to server attributes + Output out = new Output(); + out.setType(OutputType.ATTRIBUTES); + out.setScope(AttributeScope.SERVER_SCOPE); + cfg.setOutput(out); + + cf.setConfiguration(cfg); + + doPost("/api/calculatedField", cf, CalculatedField.class); + + // --- Assert initial evaluation (ENTERED / OUTSIDE) --- + await().alias("initial geofencing evaluation") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs = getServerAttributes(device.getId(), + "allowedZonesEvent", "allowedZonesStatus", "restrictedZonesStatus"); + assertThat(attrs).isNotNull().isNotEmpty().hasSize(3); + Map m = kv(attrs); + assertThat(m).containsEntry("allowedZonesEvent", "ENTERED") + .containsEntry("allowedZonesStatus", "INSIDE") + .containsEntry("restrictedZonesStatus", "OUTSIDE"); + }); + + // --- Move the device into Restricted zone (and outside Allowed) --- + doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope", + JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}")); + + // --- Assert transition (LEFT / ENTERED) --- + await().alias("transition evaluation after movement") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs = getServerAttributes(device.getId(), + "allowedZonesEvent", "allowedZonesStatus", + "restrictedZonesEvent", "restrictedZonesStatus"); + assertThat(attrs).isNotNull().isNotEmpty().hasSize(4); + Map m = kv(attrs); + assertThat(m).containsEntry("allowedZonesEvent", "LEFT") + .containsEntry("restrictedZonesEvent", "ENTERED") + .containsEntry("allowedZonesStatus", "OUTSIDE") + .containsEntry("restrictedZonesStatus", "INSIDE"); + }); + } + + @Test + public void testGeofencingCalculatedField_DynamicRefresh_RebindsZoneArguments() throws Exception { + // --- Update min allowed scheduled update intervals for CFs --- + loginSysAdmin(); + EntityInfo tenantProfileEntityInfo = doGet("/api/tenantProfileInfo/default", EntityInfo.class); + assertThat(tenantProfileEntityInfo).isNotNull(); + TenantProfile foundTenantProfile = doGet("/api/tenantProfile/" + tenantProfileEntityInfo.getId().getId().toString(), TenantProfile.class); + assertThat(foundTenantProfile).isNotNull(); + assertThat(foundTenantProfile.getDefaultProfileConfiguration()).isNotNull(); + foundTenantProfile.getDefaultProfileConfiguration().setMinAllowedScheduledUpdateIntervalInSecForCF(TIMEOUT / 10); + TenantProfile savedTenantProfile = doPost("/api/tenantProfile", foundTenantProfile, TenantProfile.class); + assertThat(savedTenantProfile).isNotNull(); + assertThat(savedTenantProfile.getDefaultProfileConfiguration().getMinAllowedScheduledUpdateIntervalInSecForCF()).isEqualTo(TIMEOUT / 10); + loginTenantAdmin(); + + // --- Arrange entities --- + Device device = createDevice("GF Device dyn", "sn-geo-dyn-1"); + + // Allowed Zone A: covers initial point (ENTERED) + String allowedPolygonA = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"; + + Asset allowedZoneA = createAsset("Allowed Zone A", null); + doPost("/api/plugins/telemetry/ASSET/" + allowedZoneA.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, + JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygonA + "}")).andExpect(status().isOk()); + + // Relation from device to Allowed Zone A + EntityRelation relAllowedA = new EntityRelation(); + relAllowedA.setFrom(device.getId()); + relAllowedA.setTo(allowedZoneA.getId()); + relAllowedA.setType("AllowedZone"); + doPost("/api/relation", relAllowedA).andExpect(status().isOk()); + + // Initial device coordinates: INSIDE Zone A + doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope", + JacksonUtil.toJsonNode("{\"latitude\":50.4730,\"longitude\":30.5050}")).andExpect(status().isOk()); + + // --- Build CF: GEOFENCING with dynamic 'allowedZones' and short scheduled refresh --- + CalculatedField cf = new CalculatedField(); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.GEOFENCING); + cf.setName("Geofencing CF (dynamic refresh)"); + cf.setDebugSettings(DebugSettings.off()); + + GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration(); + 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); + allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration); + cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup)); + + // Server attributes output + Output out = new Output(); + out.setType(OutputType.ATTRIBUTES); + out.setScope(AttributeScope.SERVER_SCOPE); + cfg.setOutput(out); + + // Enable scheduled refresh with a 6-second interval + cfg.setScheduledUpdateInterval(6); + + cf.setConfiguration(cfg); + CalculatedField savedCalculatedField = doPost("/api/calculatedField", cf, CalculatedField.class); + assertThat(savedCalculatedField).isNotNull(); + CalculatedFieldConfiguration configuration = savedCalculatedField.getConfiguration(); + assertThat(configuration).isInstanceOf(GeofencingCalculatedFieldConfiguration.class); + var geofencingConfiguration = (GeofencingCalculatedFieldConfiguration) configuration; + assertThat(geofencingConfiguration.isScheduledUpdateEnabled()).isTrue(); + + // --- Assert initial evaluation (ENTERED) --- + await().alias("initial geofencing evaluation") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus"); + assertThat(attrs).isNotNull().isNotEmpty().hasSize(2); + Map m = kv(attrs); + assertThat(m).containsEntry("allowedZonesEvent", "ENTERED") + .containsEntry("allowedZonesStatus", "INSIDE"); + }); + + // --- Move device OUTSIDE Zone A (expect LEFT) --- + doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope", + JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}")).andExpect(status().isOk()); + + await().alias("outside zone A (LEFT)") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus"); + assertThat(attrs).isNotNull().isNotEmpty().hasSize(2); + Map m = kv(attrs); + assertThat(m).containsEntry("allowedZonesEvent", "LEFT") + .containsEntry("allowedZonesStatus", "OUTSIDE"); + }); + + // --- Create Allowed Zone B covering the CURRENT location --- + String allowedPolygonB = "[[50.475500, 30.510500], [50.475500, 30.511500], [50.476500, 30.511500], [50.476500, 30.510500]]"; + + Asset allowedZoneB = createAsset("Allowed Zone B", null); + doPost("/api/plugins/telemetry/ASSET/" + allowedZoneB.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, + JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygonB + "}")).andExpect(status().isOk()); + + // Add a new relation + EntityRelation relAllowedB = new EntityRelation(); + relAllowedB.setFrom(device.getId()); + relAllowedB.setTo(allowedZoneB.getId()); + relAllowedB.setType("AllowedZone"); + doPost("/api/relation", relAllowedB).andExpect(status().isOk()); + + awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsDirty(device.getId(), savedCalculatedField.getId()); + + // --- Same coordinates as before, but now we expect ENTERED since a new zone is registered --- + doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope", + JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}")).andExpect(status().isOk()); + + // --- Assert dynamic refresh picks up new relation and flips event back to ENTERED on the next telemetry update --- + await().alias("dynamic refresh rebinds allowedZones") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(1, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus"); + assertThat(attrs).isNotNull().isNotEmpty().hasSize(2); + Map m = kv(attrs); + assertThat(m).containsEntry("allowedZonesEvent", "ENTERED") + .containsEntry("allowedZonesStatus", "INSIDE"); + }); + } + 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); } @@ -621,4 +969,12 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes return doPost("/api/asset", asset, Asset.class); } + private static Map kv(ArrayNode attrs) { + Map m = new HashMap<>(); + for (JsonNode n : attrs) { + m.put(n.get("key").asText(), n.get("value").asText()); + } + return m; + } + } 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 b93ff0f623..fd01581e36 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -68,7 +68,10 @@ import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.server.actors.DefaultTbActorSystem; import org.thingsboard.server.actors.TbActorId; import org.thingsboard.server.actors.TbActorMailbox; +import org.thingsboard.server.actors.TbCalculatedFieldEntityActorId; import org.thingsboard.server.actors.TbEntityActorId; +import org.thingsboard.server.actors.calculatedField.CalculatedFieldEntityActor; +import org.thingsboard.server.actors.calculatedField.CalculatedFieldEntityMessageProcessor; import org.thingsboard.server.actors.device.DeviceActor; import org.thingsboard.server.actors.device.DeviceActorMessageProcessor; import org.thingsboard.server.actors.device.SessionInfo; @@ -99,6 +102,7 @@ import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadCo import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; @@ -150,6 +154,7 @@ import org.thingsboard.server.dao.tenant.TenantProfileService; 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.entitiy.tenant.profile.TbTenantProfileService; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRequest; import org.thingsboard.server.service.security.auth.rest.LoginRequest; @@ -1099,6 +1104,17 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { }); } + protected void awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsDirty(EntityId entityId, CalculatedFieldId cfId) { + 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(() -> { + CalculatedFieldState calculatedFieldState = statesMap.get(cfId); + boolean stateDirty = calculatedFieldState != null && calculatedFieldState.isDirty(); + log.warn("entityId {}, cfId {}, state dirty == {}", entityId, cfId, stateDirty); + return stateDirty; + }); + } + protected static String getMapName(FeatureType featureType) { switch (featureType) { case ATTRIBUTES: @@ -1120,6 +1136,16 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return (DeviceActorMessageProcessor) ReflectionTestUtils.getField(actor, "processor"); } + protected CalculatedFieldEntityMessageProcessor getCalculatedFieldEntityMessageProcessor(EntityId entityId) { + DefaultTbActorSystem actorSystem = (DefaultTbActorSystem) ReflectionTestUtils.getField(actorService, "system"); + ConcurrentMap actors = (ConcurrentMap) ReflectionTestUtils.getField(actorSystem, "actors"); + Awaitility.await("CF entity actor was created").atMost(TIMEOUT, TimeUnit.SECONDS) + .until(() -> actors.containsKey(new TbCalculatedFieldEntityActorId(entityId))); + TbActorMailbox actorMailbox = actors.get(new TbCalculatedFieldEntityActorId(entityId)); + CalculatedFieldEntityActor actor = (CalculatedFieldEntityActor) ReflectionTestUtils.getField(actorMailbox, "actor"); + return (CalculatedFieldEntityMessageProcessor) ReflectionTestUtils.getField(actor, "processor"); + } + protected void updateDefaultTenantProfileConfig(Consumer updater) throws ThingsboardException { updateDefaultTenantProfile(tenantProfile -> { TenantProfileData profileData = tenantProfile.getProfileData(); 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 new file mode 100644 index 0000000000..691a1f7ec4 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java @@ -0,0 +1,483 @@ +/** + * 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.google.common.util.concurrent.Futures; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.JacksonUtil; +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.geofencing.EntityCoordinates; +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.ZoneGroupConfiguration; +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.BaseAttributeKvEntry; +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.dao.relation.RelationService; +import org.thingsboard.server.dao.usagerecord.ApiLimitService; +import org.thingsboard.server.service.cf.CalculatedFieldResult; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; + +import java.util.HashMap; +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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +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; +import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS; + +@ExtendWith(MockitoExtension.class) +public class GeofencingCalculatedFieldStateTest { + + private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("8f83eeca-b5cd-4955-9241-09d1393768c6")); + private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("688b529d-cfbe-4430-91c5-60b4f4e5d3cf")); + private final AssetId ZONE_1_ID = new AssetId(UUID.fromString("c0e3031c-7df1-45e4-9590-cfd621a4d714")); + private final AssetId ZONE_2_ID = new AssetId(UUID.fromString("e7da6200-2096-4038-a343-ade9ea4fa3e4")); + + private final SingleValueArgumentEntry latitudeArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis() - 10, new DoubleDataEntry("latitude", 50.4730), 145L); + private final SingleValueArgumentEntry longitudeArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis() - 6, new DoubleDataEntry("longitude", 30.5050), 165L); + + private final JsonDataEntry allowedZoneDataEntry = new JsonDataEntry("zone", "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"); + private final BaseAttributeKvEntry allowedZoneAttributeKvEntry = new BaseAttributeKvEntry(allowedZoneDataEntry, System.currentTimeMillis(), 0L); + private final GeofencingArgumentEntry geofencingAllowedZoneArgEntry = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry)); + + private final JsonDataEntry restrictedZoneDataEntry = new JsonDataEntry("zone", "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]"); + private final BaseAttributeKvEntry restrictedZoneAttributeKvEntry = new BaseAttributeKvEntry(restrictedZoneDataEntry, System.currentTimeMillis(), 0L); + private final GeofencingArgumentEntry geofencingRestrictedZoneArgEntry = new GeofencingArgumentEntry(Map.of(ZONE_2_ID, restrictedZoneAttributeKvEntry)); + + + private GeofencingCalculatedFieldState state; + private CalculatedFieldCtx ctx; + + @Mock + private ApiLimitService apiLimitService; + @Mock + private RelationService relationService; + + @BeforeEach + void setUp() { + when(apiLimitService.getLimit(any(), any())).thenReturn(1000L); + ctx = new CalculatedFieldCtx(getCalculatedField(), null, apiLimitService, relationService); + ctx.init(); + state = new GeofencingCalculatedFieldState(ctx.getArgNames()); + } + + @Test + void testType() { + assertThat(state.getType()).isEqualTo(CalculatedFieldType.GEOFENCING); + } + + @Test + void testUpdateState() { + state.arguments = new HashMap<>(Map.of( + ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry + )); + + Map newArgs = Map.of("allowedZones", geofencingAllowedZoneArgEntry); + boolean stateUpdated = state.updateState(ctx, newArgs); + + assertThat(stateUpdated).isTrue(); + assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf( + Map.of( + ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, + "allowedZones", geofencingAllowedZoneArgEntry + ) + ); + } + + @Test + void testUpdateStateWithInvalidArgumentTypeForLatitudeArgument() { + assertThatThrownBy(() -> state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry))) + .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))) + .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))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for someArgumentName argument: SINGLE_VALUE. Only GEOFENCING type is allowed."); + } + + @Test + void testUpdateStateWhenUpdateExistingSingleValueArgumentEntry() { + state.arguments = new HashMap<>(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry)); + + SingleValueArgumentEntry newArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 190L); + Map newArgs = Map.of("latitude", newArgEntry); + boolean stateUpdated = state.updateState(ctx, newArgs); + + assertThat(stateUpdated).isTrue(); + assertThat(state.getArguments()).isEqualTo(newArgs); + } + + @Test + void testUpdateStateWhenUpdateExistingGeofencingValueArgumentEntryWithTheSameValue() { + state.arguments = new HashMap<>(Map.of("allowedZones", geofencingAllowedZoneArgEntry)); + + Map newArgs = Map.of("allowedZones", geofencingAllowedZoneArgEntry); + + boolean stateUpdated = state.updateState(ctx, newArgs); + + assertThat(stateUpdated).isFalse(); + assertThat(state.getArguments()).isEqualTo(newArgs); + } + + @Test + 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))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for single value argument entry: GEOFENCING"); + } + + + @Test + void testUpdateStateWhenUpdateExistingGeofencingValueArgumentEntryWithValueOfAnotherType() { + state.arguments = new HashMap<>(Map.of("allowedZones", geofencingAllowedZoneArgEntry)); + + assertThatThrownBy(() -> state.updateState(ctx, Map.of("allowedZones", latitudeArgEntry))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for geofencing argument entry: SINGLE_VALUE"); + } + + @Test + void testIsReadyWhenNotAllArgPresent() { + assertThat(state.isReady()).isFalse(); + } + + @Test + void testIsReadyWhenAllArgPresent() { + state.arguments = new HashMap<>(Map.of( + ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, + "allowedZones", geofencingAllowedZoneArgEntry, + "restrictedZones", geofencingRestrictedZoneArgEntry + )); + assertThat(state.isReady()).isTrue(); + } + + @Test + void testIsReadyWhenEmptyEntryPresents() { + state.arguments = new HashMap<>(Map.of( + ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, + "allowedZones", geofencingAllowedZoneArgEntry, + "restrictedZones", geofencingRestrictedZoneArgEntry + )); + + state.getArguments().put("noParkingZones", new GeofencingArgumentEntry()); + + assertThat(state.isReady()).isFalse(); + } + + @Test + void testPerformCalculation() throws ExecutionException, InterruptedException { + state.arguments = new HashMap<>(Map.of( + ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, + "allowedZones", geofencingAllowedZoneArgEntry, + "restrictedZones", geofencingRestrictedZoneArgEntry + )); + + Output output = ctx.getOutput(); + var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); + + 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(); + + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(output.getType()); + assertThat(result.getScope()).isEqualTo(output.getScope()); + assertThat(result.getResult()).isEqualTo( + JacksonUtil.newObjectNode() + .put("allowedZonesEvent", "ENTERED") + .put("allowedZonesStatus", "INSIDE") + .put("restrictedZonesStatus", "OUTSIDE") + ); + + SingleValueArgumentEntry newLatitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 146L); + 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)); + + CalculatedFieldResult result2 = state.performCalculation(ctx.getEntityId(), ctx).get(); + + assertThat(result2).isNotNull(); + assertThat(result2.getType()).isEqualTo(output.getType()); + assertThat(result2.getScope()).isEqualTo(output.getScope()); + assertThat(result2.getResult()).isEqualTo( + JacksonUtil.newObjectNode() + .put("allowedZonesEvent", "LEFT") + .put("allowedZonesStatus", "OUTSIDE") + .put("restrictedZonesEvent", "ENTERED") + .put("restrictedZonesStatus", "INSIDE") + ); + + // Check relations are created and deleted correctly for both iterations. + ArgumentCaptor saveCaptor = ArgumentCaptor.forClass(EntityRelation.class); + verify(relationService, times(2)).saveRelationAsync(eq(ctx.getTenantId()), saveCaptor.capture()); + List saveValues = saveCaptor.getAllValues(); + assertThat(saveValues).hasSize(2); + + EntityRelation relationFromFirstIteration = saveValues.get(0); + assertThat(relationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); + assertThat(relationFromFirstIteration.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(relationFromFirstIteration.getType()).isEqualTo("CurrentZone"); + + EntityRelation relationFromSecondIteration = saveValues.get(1); + assertThat(relationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId()); + assertThat(relationFromSecondIteration.getFrom()).isEqualTo(ZONE_2_ID); + assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone"); + + ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class); + verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); + EntityRelation leftRelation = deleteCaptor.getValue(); + assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId()); + } + + @Test + void testPerformCalculationWithOnlyTransitionEventsReportingStrategy() throws ExecutionException, InterruptedException { + state.arguments = new HashMap<>(Map.of( + ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, + "allowedZones", geofencingAllowedZoneArgEntry, + "restrictedZones", geofencingRestrictedZoneArgEntry + )); + + Output output = ctx.getOutput(); + + var calculatedFieldConfig = getCalculatedFieldConfig(GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_ONLY); + + ctx.setCalculatedField(getCalculatedField(calculatedFieldConfig)); + ctx.init(); + + var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); + + 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(); + + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(output.getType()); + assertThat(result.getScope()).isEqualTo(output.getScope()); + assertThat(result.getResult()).isEqualTo( + JacksonUtil.newObjectNode().put("allowedZonesEvent", "ENTERED") + ); + + SingleValueArgumentEntry newLatitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 146L); + 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)); + + CalculatedFieldResult result2 = state.performCalculation(ctx.getEntityId(), ctx).get(); + + assertThat(result2).isNotNull(); + assertThat(result2.getType()).isEqualTo(output.getType()); + assertThat(result2.getScope()).isEqualTo(output.getScope()); + assertThat(result2.getResult()).isEqualTo( + JacksonUtil.newObjectNode() + .put("allowedZonesEvent", "LEFT") + .put("restrictedZonesEvent", "ENTERED") + ); + + // Check relations are created and deleted correctly for both iterations. + ArgumentCaptor saveCaptor = ArgumentCaptor.forClass(EntityRelation.class); + verify(relationService, times(2)).saveRelationAsync(eq(ctx.getTenantId()), saveCaptor.capture()); + List saveValues = saveCaptor.getAllValues(); + assertThat(saveValues).hasSize(2); + + EntityRelation relationFromFirstIteration = saveValues.get(0); + assertThat(relationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); + assertThat(relationFromFirstIteration.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(relationFromFirstIteration.getType()).isEqualTo("CurrentZone"); + + EntityRelation relationFromSecondIteration = saveValues.get(1); + assertThat(relationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId()); + assertThat(relationFromSecondIteration.getFrom()).isEqualTo(ZONE_2_ID); + assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone"); + + ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class); + verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); + EntityRelation leftRelation = deleteCaptor.getValue(); + assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId()); + } + + @Test + void testPerformCalculationWithOnlyPresenceStatusReportingStrategy() throws ExecutionException, InterruptedException { + state.arguments = new HashMap<>(Map.of( + ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, + "allowedZones", geofencingAllowedZoneArgEntry, + "restrictedZones", geofencingRestrictedZoneArgEntry + )); + + Output output = ctx.getOutput(); + + var calculatedFieldConfig = getCalculatedFieldConfig(GeofencingReportStrategy.REPORT_PRESENCE_STATUS_ONLY); + + ctx.setCalculatedField(getCalculatedField(calculatedFieldConfig)); + ctx.init(); + + var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); + + 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(); + + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(output.getType()); + assertThat(result.getScope()).isEqualTo(output.getScope()); + assertThat(result.getResult()).isEqualTo( + JacksonUtil.newObjectNode() + .put("allowedZonesStatus", "INSIDE") + .put("restrictedZonesStatus", "OUTSIDE") + ); + + SingleValueArgumentEntry newLatitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 146L); + 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)); + + CalculatedFieldResult result2 = state.performCalculation(ctx.getEntityId(), ctx).get(); + + assertThat(result2).isNotNull(); + assertThat(result2.getType()).isEqualTo(output.getType()); + assertThat(result2.getScope()).isEqualTo(output.getScope()); + assertThat(result2.getResult()).isEqualTo( + JacksonUtil.newObjectNode() + .put("allowedZonesStatus", "OUTSIDE") + .put("restrictedZonesStatus", "INSIDE") + ); + + // Check relations are created and deleted correctly for both iterations. + ArgumentCaptor saveCaptor = ArgumentCaptor.forClass(EntityRelation.class); + verify(relationService, times(2)).saveRelationAsync(eq(ctx.getTenantId()), saveCaptor.capture()); + List saveValues = saveCaptor.getAllValues(); + assertThat(saveValues).hasSize(2); + + EntityRelation relationFromFirstIteration = saveValues.get(0); + assertThat(relationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); + assertThat(relationFromFirstIteration.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(relationFromFirstIteration.getType()).isEqualTo("CurrentZone"); + + EntityRelation relationFromSecondIteration = saveValues.get(1); + assertThat(relationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId()); + assertThat(relationFromSecondIteration.getFrom()).isEqualTo(ZONE_2_ID); + assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone"); + + ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class); + verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); + EntityRelation leftRelation = deleteCaptor.getValue(); + assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId()); + } + + private CalculatedField getCalculatedField() { + return getCalculatedField(getCalculatedFieldConfig(REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS)); + } + + private CalculatedField getCalculatedField(CalculatedFieldConfiguration configuration) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setTenantId(TENANT_ID); + calculatedField.setEntityId(DEVICE_ID); + calculatedField.setType(CalculatedFieldType.GEOFENCING); + calculatedField.setName("Test Geofencing Calculated Field"); + calculatedField.setConfigurationVersion(1); + calculatedField.setConfiguration(configuration); + calculatedField.setVersion(1L); + return calculatedField; + } + + private CalculatedFieldConfiguration getCalculatedFieldConfig(GeofencingReportStrategy reportStrategy) { + var config = new GeofencingCalculatedFieldConfiguration(); + + EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude"); + 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); + 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); + restrictedZonesGroup.setRefDynamicSourceConfiguration(restrictedZoneDynamicSourceConfiguration); + restrictedZonesGroup.setRelationType("CurrentZone"); + restrictedZonesGroup.setDirection(EntitySearchDirection.TO); + + config.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup)); + + Output output = new Output(); + output.setType(OutputType.TIME_SERIES); + config.setOutput(output); + return config; + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java new file mode 100644 index 0000000000..6da4bdc882 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java @@ -0,0 +1,184 @@ +/** + * 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 io.hypersistence.utils.hibernate.type.json.internal.JacksonUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.common.util.geo.PerimeterDefinition; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.EntityId; +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.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState; + +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 GeofencingValueArgumentEntryTest { + + private final AssetId ZONE_1_ID = new AssetId(UUID.fromString("c0e3031c-7df1-45e4-9590-cfd621a4d714")); + private final AssetId ZONE_2_ID = new AssetId(UUID.fromString("e7da6200-2096-4038-a343-ade9ea4fa3e4")); + + private final JsonDataEntry allowedZoneDataEntry = new JsonDataEntry("zone", "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"); + private final BaseAttributeKvEntry allowedZoneAttributeKvEntry = new BaseAttributeKvEntry(allowedZoneDataEntry, 363L, 155L); + + private final JsonDataEntry restrictedZoneDataEntry = new JsonDataEntry("zone", "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]"); + private final BaseAttributeKvEntry restrictedZoneAttributeKvEntry = new BaseAttributeKvEntry(restrictedZoneDataEntry, 363L, 155L); + + private GeofencingArgumentEntry entry; + + @BeforeEach + void setUp() { + entry = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry, ZONE_2_ID, restrictedZoneAttributeKvEntry)); + } + + @Test + void testArgumentEntryType() { + assertThat(entry.getType()).isEqualTo(ArgumentEntryType.GEOFENCING); + } + + @Test + void testUpdateEntryWhenSingleEntryPassed() { + assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for geofencing argument entry: SINGLE_VALUE"); + } + + @Test + void testUpdateEntryWhenRollingEntryPassed() { + assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for geofencing argument entry: TS_ROLLING"); + } + + @Test + void testUpdateEntryWithTheSameTs() { + BaseAttributeKvEntry differentValueSameTs = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 363L, 156L); + var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueSameTs, ZONE_2_ID, restrictedZoneAttributeKvEntry)); + assertThat(entry.updateEntry(updated)).isFalse(); + } + + @Test + @SuppressWarnings("unchecked") + void testUpdateEntryWhenNewVersionIsNull() { + BaseAttributeKvEntry differentValueNewVersionIsNull = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, null); + var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsNull, ZONE_2_ID, restrictedZoneAttributeKvEntry)); + + assertThat(entry.updateEntry(updated)).isTrue(); + assertThat(entry.getValue()).isInstanceOf(Map.class); + + Map value = (Map) entry.getValue(); + assertThat(value).hasSize(2); + assertThat(value.get(ZONE_1_ID).getVersion()).isNull(); + assertThat(value.get(ZONE_1_ID).getTs()).isEqualTo(364L); + assertThat(value.get(ZONE_1_ID).getPerimeterDefinition()) + .isEqualTo(JacksonUtil.fromString(differentValueNewVersionIsNull.getJsonValue().get(), PerimeterDefinition.class)); + + assertThat(value.get(ZONE_2_ID).getVersion()).isEqualTo(155L); + assertThat(value.get(ZONE_2_ID).getTs()).isEqualTo(363L); + assertThat(value.get(ZONE_2_ID).getPerimeterDefinition()) + .isEqualTo(JacksonUtil.fromString(restrictedZoneAttributeKvEntry.getJsonValue().get(), PerimeterDefinition.class)); + } + + @Test + @SuppressWarnings("unchecked") + void testUpdateEntryWhenNewVersionIsGreaterThanCurrent() { + BaseAttributeKvEntry differentValueNewVersionIsSet = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 156L); + var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsSet, ZONE_2_ID, restrictedZoneAttributeKvEntry)); + + assertThat(entry.updateEntry(updated)).isTrue(); + assertThat(entry.getValue()).isInstanceOf(Map.class); + + Map value = (Map) entry.getValue(); + assertThat(value).hasSize(2); + assertThat(value.get(ZONE_1_ID).getVersion()).isEqualTo(156L); + assertThat(value.get(ZONE_1_ID).getTs()).isEqualTo(364L); + assertThat(value.get(ZONE_1_ID).getPerimeterDefinition()) + .isEqualTo(JacksonUtil.fromString(differentValueNewVersionIsSet.getJsonValue().get(), PerimeterDefinition.class)); + + assertThat(value.get(ZONE_2_ID).getVersion()).isEqualTo(155L); + assertThat(value.get(ZONE_2_ID).getTs()).isEqualTo(363L); + assertThat(value.get(ZONE_2_ID).getPerimeterDefinition()) + .isEqualTo(JacksonUtil.fromString(restrictedZoneAttributeKvEntry.getJsonValue().get(), PerimeterDefinition.class)); + } + + @Test + void testUpdateEntryWhenNewVersionIsLessThanCurrent() { + BaseAttributeKvEntry differentValueNewVersionIsSet = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 154L); + var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsSet, ZONE_2_ID, restrictedZoneAttributeKvEntry)); + + assertThat(entry.updateEntry(updated)).isFalse(); + } + + @Test + void testUpdateEntryWhenNewTsAndVersionIsGreaterThenCurrentAndValueWasNotChanged() { + BaseAttributeKvEntry newTsAndTheSameValue = new BaseAttributeKvEntry(allowedZoneDataEntry, 364L, 156L); + var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, newTsAndTheSameValue, ZONE_2_ID, restrictedZoneAttributeKvEntry)); + + assertThat(entry.updateEntry(updated)).isTrue(); + } + + @Test + void testUpdateEntryWithOldTs() { + BaseAttributeKvEntry oldTsAndTheSameValue = new BaseAttributeKvEntry(allowedZoneDataEntry, 362L, 156L); + var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, oldTsAndTheSameValue, ZONE_2_ID, restrictedZoneAttributeKvEntry)); + + assertThat(entry.updateEntry(updated)).isFalse(); + } + + @Test + void testUpdateEntryWithNewZone() { + final AssetId NEW_ZONE_ID = new AssetId(UUID.fromString("a3eacf1a-6af3-4e9f-87c4-502bb25c7dc3")); + BaseAttributeKvEntry newZone = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 156L); + var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry, ZONE_2_ID, restrictedZoneAttributeKvEntry, NEW_ZONE_ID, newZone)); + assertThat(entry.updateEntry(updated)).isTrue(); + } + + @Test + void testIsEmpty() { + GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry(); + assertThat(geofencingArgumentEntry.isEmpty()).isTrue(); + } + + @Test + void testIsEmptyWithEmptyMap() { + GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry(Map.of()); + assertThat(geofencingArgumentEntry.isEmpty()).isTrue(); + } + + @Test + void testInvalidKvEntryDataTypeForZoneResultInEmptyArgument() { + BaseAttributeKvEntry invalidZoneEntry = new BaseAttributeKvEntry(new StringDataEntry("zone", "someString"), 363L, 155L); + assertThatThrownBy(() -> new GeofencingArgumentEntry(Map.of(ZONE_1_ID, invalidZoneEntry))) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessage("The given string value cannot be transformed to Json object: someString"); + } + + @Test + void testNotParsableToPerimeterJsonKvEntryResultInExceptionTrowed() { + BaseAttributeKvEntry invalidZoneEntry = new BaseAttributeKvEntry(new JsonDataEntry("zone", "\"{}\""), 363L, 155L); + assertThatThrownBy(() -> new GeofencingArgumentEntry(Map.of(ZONE_1_ID, invalidZoneEntry))) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessage("The given string value cannot be transformed to Json object: \"{}\""); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java new file mode 100644 index 0000000000..f6c6778ced --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java @@ -0,0 +1,169 @@ +/** + * 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.common.util.geo.Coordinates; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.JsonDataEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingEvalResult; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.INSIDE; +import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.OUTSIDE; +import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent.ENTERED; +import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent.LEFT; + +public class GeofencingZoneStateTest { + + private final AssetId ZONE_ID = new AssetId(UUID.fromString("628730fd-d625-417f-9c6d-ae9fe4addbdb")); + + private GeofencingZoneState state; + + @BeforeEach + void setUp() { + String POLYGON = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"; + state = new GeofencingZoneState(ZONE_ID, new BaseAttributeKvEntry(new JsonDataEntry("zone", POLYGON), 100L, 1L)); + } + + @Test + void evaluate_initialInside_thenInsideAgain() { + var inside = new Coordinates(50.4730, 30.5050); + // first evaluation: no prior state -> ENTERED + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE)); + // same position again -> INSIDE (steady state) + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE)); + } + + @Test + void evaluate_initialOutside_thenOutsideAgain() { + var outside = new Coordinates(50.4760, 30.5110); + // first evaluation: no prior state -> OUTSIDE + assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE)); + // same position again -> OUTSIDE (steady state) + assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE)); + } + + @Test + void evaluate_inside_thenLeave() { + var inside = new Coordinates(50.4730, 30.5050); + var outside = new Coordinates(50.4760, 30.5110); + // enter + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE)); + // leave -> LEFT + assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(LEFT, OUTSIDE)); + // still outside -> OUTSIDE + assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE)); + } + + @Test + void evaluate_outside_thenEnter() { + var outside = new Coordinates(50.4760, 30.5110); + var inside = new Coordinates(50.4730, 30.5050); + // start outside + assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE)); + // cross boundary -> ENTERED + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE)); + // remain inside -> INSIDE + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE)); + } + + @Test + void update_withNewerVersion_updatesState_andResetsPresence() { + // arrange: establish a prior presence to ensure it’s reset on update + var inside = new Coordinates(50.4730, 30.5050); + assertThat(state.evaluate(inside)).isNotNull(); // sets lastPresence internally + + String NEW_POLYGON = "[[50.470000, 30.502000], [50.470000, 30.503000], [50.471000, 30.503000], [50.471000, 30.502000]]"; + GeofencingZoneState newer = new GeofencingZoneState( + ZONE_ID, + new BaseAttributeKvEntry(new JsonDataEntry("zone", NEW_POLYGON), 200L, 2L) + ); + + // act + boolean changed = state.update(newer); + + // assert + assertThat(changed).isTrue(); + assertThat(state.getTs()).isEqualTo(200L); + assertThat(state.getVersion()).isEqualTo(2L); + assertThat(state.getPerimeterDefinition()).isNotNull(); + assertThat(state.getLastPresence()).isNull(); // must be reset on successful update + } + + @Test + void update_withEqualVersion_doesNothing() { + // arrange: same version (1L) but different ts/polygon should still be ignored + String SOME_POLYGON = "[[50.472500, 30.504500], [50.472500, 30.505500], [50.473500, 30.505500], [50.473500, 30.504500]]"; + GeofencingZoneState sameVersion = new GeofencingZoneState( + ZONE_ID, + new BaseAttributeKvEntry(new JsonDataEntry("zone", SOME_POLYGON), 300L, 1L) + ); + + // act + boolean changed = state.update(sameVersion); + + // assert: nothing changes + assertThat(changed).isFalse(); + assertThat(state.getTs()).isEqualTo(100L); + assertThat(state.getVersion()).isEqualTo(1L); + } + + @Test + void update_withNullNewVersion_alwaysApplies_andCopiesNull() { + // arrange: the implementation updates if newVersion == null + String OTHER_POLYGON = "[[50.471000, 30.506000], [50.471000, 30.507000], [50.472000, 30.507000], [50.472000, 30.506000]]"; + GeofencingZoneState nullVersion = new GeofencingZoneState( + ZONE_ID, + new BaseAttributeKvEntry(new JsonDataEntry("zone", OTHER_POLYGON), 400L, null) + ); + + // act + boolean changed = state.update(nullVersion); + + // assert: applied and version copied as null + assertThat(changed).isTrue(); + assertThat(state.getTs()).isEqualTo(400L); + assertThat(state.getVersion()).isNull(); + assertThat(state.getLastPresence()).isNull(); + } + + @Test + void update_withNewVersionWhenExistingIsNull_alwaysApplies_andCopiesNew() { + // arrange: the implementation updates if newVersion == null + String OTHER_POLYGON = "[[50.471000, 30.506000], [50.471000, 30.507000], [50.472000, 30.507000], [50.472000, 30.506000]]"; + GeofencingZoneState newVersion = new GeofencingZoneState( + ZONE_ID, + new BaseAttributeKvEntry(new JsonDataEntry("zone", OTHER_POLYGON), 400L, 2L) + ); + state.setVersion(null); + + // act + boolean changed = state.update(newVersion); + + // assert: applied and version copied as null + assertThat(changed).isTrue(); + assertThat(state.getTs()).isEqualTo(400L); + assertThat(state.getVersion()).isEqualTo(2); + assertThat(state.getLastPresence()).isNull(); + } + +} 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 8ed42c43e8..8c714bc0e7 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 @@ -44,6 +44,7 @@ import org.thingsboard.server.dao.usagerecord.ApiLimitService; import org.thingsboard.server.service.cf.CalculatedFieldResult; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.TreeMap; import java.util.UUID; @@ -77,7 +78,7 @@ public class ScriptCalculatedFieldStateTest { @BeforeEach void setUp() { when(apiLimitService.getLimit(any(), any())).thenReturn(1000L); - ctx = new CalculatedFieldCtx(getCalculatedField(), tbelInvokeService, apiLimitService); + ctx = new CalculatedFieldCtx(getCalculatedField(), tbelInvokeService, apiLimitService, null); ctx.init(); state = new ScriptCalculatedFieldState(ctx.getArgNames()); } @@ -124,7 +125,7 @@ public class ScriptCalculatedFieldStateTest { void testPerformCalculation() throws ExecutionException, InterruptedException { state.arguments = new HashMap<>(Map.of("deviceTemperature", deviceTemperatureArgEntry, "assetHumidity", assetHumidityArgEntry)); - CalculatedFieldResult result = state.performCalculation(ctx).get(); + CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get(); assertThat(result).isNotNull(); Output output = getCalculatedFieldConfig().getOutput(); @@ -140,7 +141,7 @@ public class ScriptCalculatedFieldStateTest { "assetHumidity", new SingleValueArgumentEntry(System.currentTimeMillis() - 10, new LongDataEntry("a", 45L), 10L) )); - CalculatedFieldResult result = state.performCalculation(ctx).get(); + CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get(); assertThat(result).isNotNull(); Output output = getCalculatedFieldConfig().getOutput(); 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 c1616a85db..8c631ecf6f 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 @@ -71,7 +71,7 @@ public class SimpleCalculatedFieldStateTest { @BeforeEach void setUp() { when(apiLimitService.getLimit(any(), any())).thenReturn(1000L); - ctx = new CalculatedFieldCtx(getCalculatedField(), null, apiLimitService); + ctx = new CalculatedFieldCtx(getCalculatedField(), null, apiLimitService, null); ctx.init(); state = new SimpleCalculatedFieldState(ctx.getArgNames()); } @@ -134,7 +134,7 @@ public class SimpleCalculatedFieldStateTest { "key3", key3ArgEntry )); - CalculatedFieldResult result = state.performCalculation(ctx).get(); + CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get(); assertThat(result).isNotNull(); Output output = getCalculatedFieldConfig().getOutput(); @@ -151,7 +151,7 @@ public class SimpleCalculatedFieldStateTest { "key3", key3ArgEntry )); - assertThatThrownBy(() -> state.performCalculation(ctx)) + assertThatThrownBy(() -> state.performCalculation(ctx.getEntityId(), ctx)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Argument 'key2' is not a number."); } @@ -164,7 +164,7 @@ public class SimpleCalculatedFieldStateTest { "key3", key3ArgEntry )); - CalculatedFieldResult result = state.performCalculation(ctx).get(); + CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get(); assertThat(result).isNotNull(); Output output = getCalculatedFieldConfig().getOutput(); @@ -185,7 +185,7 @@ public class SimpleCalculatedFieldStateTest { output.setDecimalsByDefault(3); ctx.setOutput(output); - CalculatedFieldResult result = state.performCalculation(ctx).get(); + CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get(); assertThat(result).isNotNull(); assertThat(result.getType()).isEqualTo(output.getType()); 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 461ca5a2ec..2db78871d4 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 @@ -638,7 +638,9 @@ public class VersionControlTest extends AbstractControllerTest { assertThat(importedField.getName()).isEqualTo(deviceCalculatedField.getName()); assertThat(importedField.getType()).isEqualTo(deviceCalculatedField.getType()); assertThat(importedField.getId()).isNotEqualTo(deviceCalculatedField.getId()); - assertThat(importedField.getConfiguration().getArguments().get("T").getRefEntityId()).isEqualTo(importedAsset.getId()); + assertThat(importedField.getConfiguration()).isInstanceOf(SimpleCalculatedFieldConfiguration.class); + SimpleCalculatedFieldConfiguration simpleCfg = (SimpleCalculatedFieldConfiguration) importedField.getConfiguration(); + assertThat(simpleCfg.getArguments().get("T").getRefEntityId()).isEqualTo(importedAsset.getId()); }); List importedAssetCalculatedFields = findCalculatedFieldsByEntityId(importedAsset.getId()); @@ -647,7 +649,9 @@ public class VersionControlTest extends AbstractControllerTest { assertThat(importedField.getName()).isEqualTo(assetCalculatedField.getName()); assertThat(importedField.getType()).isEqualTo(assetCalculatedField.getType()); assertThat(importedField.getId()).isNotEqualTo(assetCalculatedField.getId()); - assertThat(importedField.getConfiguration().getArguments().get("T").getRefEntityId()).isEqualTo(importedDevice.getId()); + assertThat(importedField.getConfiguration()).isInstanceOf(SimpleCalculatedFieldConfiguration.class); + SimpleCalculatedFieldConfiguration simpleCfg = (SimpleCalculatedFieldConfiguration) importedField.getConfiguration(); + assertThat(simpleCfg.getArguments().get("T").getRefEntityId()).isEqualTo(importedDevice.getId()); }); } diff --git a/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java new file mode 100644 index 0000000000..2697b2b804 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java @@ -0,0 +1,109 @@ +/** + * 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.utils; + +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.configuration.geofencing.GeofencingPresenceStatus; +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.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.JsonDataEntry; +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.geofencing.GeofencingArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +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.thingsboard.server.utils.CalculatedFieldUtils.toProto; + +@ExtendWith(MockitoExtension.class) +class CalculatedFieldUtilsTest { + + private static final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("0a69e1e2-fcbc-4234-a4cd-3844bf54035c")); + private static final CalculatedFieldId CF_ID = CalculatedFieldId.fromString("ec0e91b9-6f27-4e93-946a-5fbc2707d8bc"); + private static final DeviceId DEVICE_ID = DeviceId.fromString("1e03bd38-2010-4739-9362-160c288e36c4"); + + @Test + void toProtoAndFromProto_shouldMapGeofencingArgumentsAndZones() { + // given + CalculatedFieldEntityCtxId stateId = mock(CalculatedFieldEntityCtxId.class); + given(stateId.tenantId()).willReturn(TENANT_ID); + given(stateId.cfId()).willReturn(CF_ID); + given(stateId.entityId()).willReturn(DEVICE_ID); + + // Build a geofencing argument with two zones (one with inside=true, one with inside=null) + GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry(); + Map zoneStates = new LinkedHashMap<>(); + + UUID zoneId1 = UUID.fromString("624a8fff-71a2-4847-a100-ff1cf52dbe71"); + UUID zoneId2 = UUID.fromString("e2adf6ce-9478-40b1-b0e9-4a6860cc46bb"); + + AssetId z1 = new AssetId(zoneId1); + AssetId z2 = new AssetId(zoneId2); + + JsonDataEntry zone1 = new JsonDataEntry("zone", "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"); + JsonDataEntry zone2 = new JsonDataEntry("zone", "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]"); + + BaseAttributeKvEntry zone1PerimeterAttribute = new BaseAttributeKvEntry(zone1, System.currentTimeMillis(), 0L); + BaseAttributeKvEntry zone2PerimeterAttribute = new BaseAttributeKvEntry(zone2, System.currentTimeMillis(), 0L); + + GeofencingZoneState s1 = new GeofencingZoneState(z1, zone1PerimeterAttribute); + s1.setLastPresence(GeofencingPresenceStatus.INSIDE); + GeofencingZoneState s2 = new GeofencingZoneState(z2, zone2PerimeterAttribute); + + zoneStates.put(z1, s1); + zoneStates.put(z2, s2); + 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)); + + // when + CalculatedFieldStateProto proto = toProto(stateId, state); + + // then + CalculatedFieldState fromProto = CalculatedFieldUtils.fromProto(proto); + assertThat(fromProto) + .usingRecursiveComparison() + .ignoringFields("requiredArguments") + .isEqualTo(state); + + ArgumentEntry fromProtoArgument = fromProto.getArguments().get("geofencingArgumentTest"); + assertThat(fromProtoArgument).isInstanceOf(GeofencingArgumentEntry.class); + GeofencingArgumentEntry fromProtoGeoArgument = (GeofencingArgumentEntry) fromProtoArgument; + assertThat(fromProtoGeoArgument.getZoneStates()).hasSize(2); + assertThat(fromProtoGeoArgument.getZoneStates().get(z1).getLastPresence()).isEqualTo(GeofencingPresenceStatus.INSIDE); + assertThat(fromProtoGeoArgument.getZoneStates().get(z2).getLastPresence()).isNull(); + } + +} 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 6da6fcf8a8..1805788007 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 @@ -38,7 +38,6 @@ import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest; import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; -import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.RestApiCallResponseMsgProto; import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg; @@ -81,7 +80,7 @@ public interface TbClusterService extends TbQueueClusterService { void pushNotificationToTransport(String targetServiceId, ToTransportMsg response, TbQueueCallback callback); - void pushMsgToCalculatedFields(TenantId tenantId, EntityId entityId, TransportProtos.ToCalculatedFieldMsg msg, TbQueueCallback callback); + void pushMsgToCalculatedFields(TenantId tenantId, EntityId entityId, ToCalculatedFieldMsg msg, TbQueueCallback callback); void pushMsgToCalculatedFields(TopicPartitionInfo tpi, UUID msgId, ToCalculatedFieldMsg msg, TbQueueCallback callback); 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 fe3eb4e4d8..f83a812529 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 @@ -38,5 +38,7 @@ public class SystemParams { String calculatedFieldDebugPerTenantLimitsConfiguration; long maxArgumentsPerCF; long maxDataPointsPerRollingArg; + int minAllowedScheduledUpdateIntervalInSecForCF; + int maxRelationLevelPerCfArgument; TrendzSettings trendzSettings; } 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 acef67a041..d4dd2c5812 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 @@ -17,6 +17,6 @@ package org.thingsboard.server.common.data.cf; public enum CalculatedFieldType { - SIMPLE, SCRIPT + SIMPLE, SCRIPT, GEOFENCING } 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 e7daa70b1b..52935c3411 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 @@ -26,10 +26,15 @@ public class Argument { @Nullable private EntityId refEntityId; + private CfArgumentDynamicSourceConfiguration refDynamicSourceConfiguration; private ReferencedEntityKey refEntityKey; private String defaultValue; private Integer limit; private Long timeWindow; + public boolean hasDynamicSource() { + return refDynamicSourceConfiguration != null; + } + } 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 new file mode 100644 index 0000000000..225278e776 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.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.cf.configuration; + +import java.util.Map; + +public interface ArgumentsBasedCalculatedFieldConfiguration extends CalculatedFieldConfiguration { + + Map getArguments(); + +} 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 8227ff4603..c270874605 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 @@ -27,7 +27,7 @@ import java.util.Objects; import java.util.stream.Collectors; @Data -public abstract class BaseCalculatedFieldConfiguration implements CalculatedFieldConfiguration { +public abstract class BaseCalculatedFieldConfiguration implements ExpressionBasedCalculatedFieldConfiguration { protected Map arguments; protected String expression; @@ -42,20 +42,13 @@ public abstract class BaseCalculatedFieldConfiguration implements CalculatedFiel } @Override - public List buildCalculatedFieldLinks(TenantId tenantId, EntityId cfEntityId, CalculatedFieldId calculatedFieldId) { - return getReferencedEntities().stream() - .filter(referencedEntity -> !referencedEntity.equals(cfEntityId)) - .map(referencedEntityId -> buildCalculatedFieldLink(tenantId, referencedEntityId, calculatedFieldId)) - .collect(Collectors.toList()); - } - - @Override - public CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId) { - CalculatedFieldLink link = new CalculatedFieldLink(); - link.setTenantId(tenantId); - link.setEntityId(referencedEntityId); - link.setCalculatedFieldId(calculatedFieldId); - return link; + public void validate() { + 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/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldInitService.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java similarity index 83% rename from application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldInitService.java rename to common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java index 6505dae581..bd2e9b0c00 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldInitService.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java @@ -13,7 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.cf; +package org.thingsboard.server.common.data.cf.configuration; + +public enum CFArgumentDynamicSourceType { + + RELATION_QUERY -public interface CalculatedFieldInitService { } 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 ad3d4373ad..972a3e0ee9 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 @@ -21,12 +21,14 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; 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.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 java.util.Collections; import java.util.List; -import java.util.Map; +import java.util.stream.Collectors; @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, @@ -35,7 +37,8 @@ import java.util.Map; ) @JsonSubTypes({ @JsonSubTypes.Type(value = SimpleCalculatedFieldConfiguration.class, name = "SIMPLE"), - @JsonSubTypes.Type(value = ScriptCalculatedFieldConfiguration.class, name = "SCRIPT") + @JsonSubTypes.Type(value = ScriptCalculatedFieldConfiguration.class, name = "SCRIPT"), + @JsonSubTypes.Type(value = GeofencingCalculatedFieldConfiguration.class, name = "GEOFENCING") }) @JsonIgnoreProperties(ignoreUnknown = true) public interface CalculatedFieldConfiguration { @@ -43,19 +46,28 @@ public interface CalculatedFieldConfiguration { @JsonIgnore CalculatedFieldType getType(); - Map getArguments(); - - String getExpression(); - - void setExpression(String expression); - Output getOutput(); + void validate(); + @JsonIgnore - List getReferencedEntities(); + default List getReferencedEntities() { + return Collections.emptyList(); + } - List buildCalculatedFieldLinks(TenantId tenantId, EntityId cfEntityId, CalculatedFieldId calculatedFieldId); + default CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId) { + CalculatedFieldLink link = new CalculatedFieldLink(); + link.setTenantId(tenantId); + link.setEntityId(referencedEntityId); + link.setCalculatedFieldId(calculatedFieldId); + return link; + } - CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId); + default List buildCalculatedFieldLinks(TenantId tenantId, EntityId cfEntityId, CalculatedFieldId calculatedFieldId) { + return getReferencedEntities().stream() + .filter(referencedEntity -> !referencedEntity.equals(cfEntityId)) + .map(referencedEntityId -> buildCalculatedFieldLink(tenantId, referencedEntityId, calculatedFieldId)) + .collect(Collectors.toList()); + } } 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 new file mode 100644 index 0000000000..f36071615e --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.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.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.JsonTypeInfo; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = RelationQueryDynamicSourceConfiguration.class, name = "RELATION_QUERY") +}) +@JsonIgnoreProperties(ignoreUnknown = true) +public interface CfArgumentDynamicSourceConfiguration { + + @JsonIgnore + CFArgumentDynamicSourceType getType(); + + default void validate() {} + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ExpressionBasedCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ExpressionBasedCalculatedFieldConfiguration.java new file mode 100644 index 0000000000..17e6a0ba80 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ExpressionBasedCalculatedFieldConfiguration.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.cf.configuration; + +public interface ExpressionBasedCalculatedFieldConfiguration extends ArgumentsBasedCalculatedFieldConfiguration { + + String getExpression(); + + void setExpression(String expression); + +} 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 new file mode 100644 index 0000000000..4e9b4252c9 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.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.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 new file mode 100644 index 0000000000..7902a9cf5b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.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.cf.configuration; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +public interface ScheduledUpdateSupportedCalculatedFieldConfiguration extends CalculatedFieldConfiguration { + + @JsonIgnore + boolean isScheduledUpdateEnabled(); + + int getScheduledUpdateInterval(); + + void setScheduledUpdateInterval(int interval); + + default void validate(long minAllowedScheduledUpdateInterval) { + if (getScheduledUpdateInterval() < minAllowedScheduledUpdateInterval) { + throw new IllegalArgumentException("Scheduled update interval is less than configured " + + "minimum allowed interval in tenant profile: " + minAllowedScheduledUpdateInterval); + } + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java index c2dde43b8e..0d38059a45 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java @@ -21,7 +21,7 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType; @Data @EqualsAndHashCode(callSuper = true) -public class ScriptCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements CalculatedFieldConfiguration { +public class ScriptCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration { @Override public CalculatedFieldType getType() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java index 86c7b9e9b6..471bac3653 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java @@ -21,7 +21,7 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType; @Data @EqualsAndHashCode(callSuper = true) -public class SimpleCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements CalculatedFieldConfiguration { +public class SimpleCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements ExpressionBasedCalculatedFieldConfiguration { private boolean useLatestTs; 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 new file mode 100644 index 0000000000..9ea5c19e8c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.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.common.data.cf.configuration.geofencing; + + +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; + +import java.util.Map; + +@Data +public class EntityCoordinates { + + public static final String ENTITY_ID_LATITUDE_ARGUMENT_KEY = "latitude"; + public static final String ENTITY_ID_LONGITUDE_ARGUMENT_KEY = "longitude"; + + private final String latitudeKeyName; + 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), + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument(longitudeKeyName) + ); + } + + private Argument toArgument(String keyName) { + var argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey(keyName, ArgumentType.TS_LATEST, null)); + return argument; + } +} 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 new file mode 100644 index 0000000000..dc331f5876 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.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.common.data.cf.configuration.geofencing; + +import com.fasterxml.jackson.annotation.JsonIgnore; +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.id.EntityId; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Data +public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration, ScheduledUpdateSupportedCalculatedFieldConfiguration { + + private EntityCoordinates entityCoordinates; + private Map zoneGroups; + private int scheduledUpdateInterval; + + private Output output; + + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.GEOFENCING; + } + + @Override + @JsonIgnore + public Map getArguments() { + Map args = new HashMap<>(entityCoordinates.toArguments()); + zoneGroups.forEach((zgName, zgConfig) -> args.put(zgName, zgConfig.toArgument())); + return args; + } + + @Override + public List getReferencedEntities() { + return zoneGroups.values().stream().map(ZoneGroupConfiguration::getRefEntityId).filter(Objects::nonNull).toList(); + } + + @Override + public Output getOutput() { + 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/GeofencingEvent.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingEvent.java new file mode 100644 index 0000000000..a6ee0cfcd6 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingEvent.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.cf.configuration.geofencing; + +public sealed interface GeofencingEvent + permits GeofencingTransitionEvent, GeofencingPresenceStatus { } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingPresenceStatus.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingPresenceStatus.java new file mode 100644 index 0000000000..38977cb650 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingPresenceStatus.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.cf.configuration.geofencing; + +import lombok.Getter; + +@Getter +public enum GeofencingPresenceStatus implements GeofencingEvent { + + INSIDE, OUTSIDE; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingReportStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingReportStrategy.java new file mode 100644 index 0000000000..a7937bb93c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingReportStrategy.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.cf.configuration.geofencing; + +public enum GeofencingReportStrategy { + + REPORT_TRANSITION_EVENTS_ONLY, + REPORT_PRESENCE_STATUS_ONLY, + REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingTransitionEvent.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingTransitionEvent.java new file mode 100644 index 0000000000..d7cf996fa7 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingTransitionEvent.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.geofencing; + +public enum GeofencingTransitionEvent implements GeofencingEvent { + ENTERED, LEFT +} 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 new file mode 100644 index 0000000000..2feb6e49d0 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java @@ -0,0 +1,95 @@ +/** + * 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.geofencing; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import org.springframework.lang.Nullable; +import org.thingsboard.server.common.data.AttributeScope; +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.CfArgumentDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ZoneGroupConfiguration { + + @Nullable + private EntityId refEntityId; + private CfArgumentDynamicSourceConfiguration refDynamicSourceConfiguration; + + private final String perimeterKeyName; + + private final GeofencingReportStrategy reportStrategy; + private final boolean createRelationsWithMatchedZones; + + private String relationType; + private EntitySearchDirection direction; + + public void validate(String name) { + 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()) { + refDynamicSourceConfiguration.validate(); + } + if (!createRelationsWithMatchedZones) { + return; + } + if (StringUtils.isBlank(relationType)) { + throw new IllegalArgumentException("Relation type must be specified for '" + name + "' zone group!"); + } + if (direction == null) { + throw new IllegalArgumentException("Relation direction must be specified for '" + name + "' zone group!"); + } + } + + public boolean hasDynamicSource() { + return refDynamicSourceConfiguration != null; + } + + @JsonIgnore + public boolean isCfEntitySource(EntityId cfEntityId) { + if (refEntityId == null && refDynamicSourceConfiguration == null) { + return true; + } + return refEntityId != null && refEntityId.equals(cfEntityId); + } + + @JsonIgnore + public boolean isLinkedCfEntitySource(EntityId cfEntityId) { + return refEntityId != null && !refEntityId.equals(cfEntityId); + } + + public Argument toArgument() { + var argument = new Argument(); + argument.setRefEntityId(refEntityId); + argument.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration); + 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/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index 246fe46791..4c8b9e06bd 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,6 +172,10 @@ 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 = "10") + private int maxRelationLevelPerCfArgument = 10; @Builder.Default @Min(value = 1, message = "must be at least 1") @Schema(example = "1000") 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 new file mode 100644 index 0000000000..fd59317649 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.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; + + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ArgumentTest { + + @Test + void validateShouldReturnFalseIfDynamicSourceConfigurationIsNull() { + var argument = new Argument(); + assertThat(argument.hasDynamicSource()).isFalse(); + } + + @Test + void validateShouldReturnTrueIfDynamicSourceConfigurationIsNotNull() { + var argument = new Argument(); + argument.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration()); + assertThat(argument.hasDynamicSource()).isTrue(); + } + +} 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 new file mode 100644 index 0000000000..86fa52ba66 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java @@ -0,0 +1,216 @@ +/** + * 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 new file mode 100644 index 0000000000..3c0956bd08 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java @@ -0,0 +1,54 @@ +/** + * 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.configuration.geofencing.GeofencingCalculatedFieldConfiguration; + +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ExtendWith(MockitoExtension.class) +public class ScheduledUpdateSupportedCalculatedFieldConfigurationTest { + + @Test + void validateShouldThrowWhenScheduledUpdateIntervalIsSetButTimeUnitIsNotSupported() { + int scheduledUpdateInterval = 60; + int minAllowedInterval = scheduledUpdateInterval - 1; + + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setScheduledUpdateInterval(scheduledUpdateInterval); + assertThatCode(() -> cfg.validate(minAllowedInterval)).doesNotThrowAnyException(); + } + + @Test + void validateShouldThrowWhenScheduledUpdateIntervalIsLessThanMinAllowedIntervalInTenantProfile() { + int minAllowedInterval = (int) TimeUnit.HOURS.toSeconds(2); + + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setScheduledUpdateInterval(1); + + assertThatThrownBy(() -> cfg.validate(minAllowedInterval)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Scheduled update interval is less than configured " + + "minimum allowed interval in tenant profile: " + minAllowedInterval); + } + +} 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 new file mode 100644 index 0000000000..c5d627c3e6 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java @@ -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. + */ +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"); + + var arguments = entityCoordinates.toArguments(); + assertThat(arguments).isNotNull().hasSize(2); + + Argument latitudeArgument = arguments.get(ENTITY_ID_LATITUDE_ARGUMENT_KEY); + assertThat(latitudeArgument).isNotNull(); + assertThat(latitudeArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("xPos", ArgumentType.TS_LATEST, null)); + assertThat(latitudeArgument.getRefEntityId()).isNull(); + assertThat(latitudeArgument.getRefDynamicSourceConfiguration()).isNull(); + + Argument longitudeArgument = arguments.get(ENTITY_ID_LONGITUDE_ARGUMENT_KEY); + assertThat(longitudeArgument).isNotNull(); + assertThat(longitudeArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("yPos", ArgumentType.TS_LATEST, null)); + assertThat(longitudeArgument.getRefEntityId()).isNull(); + assertThat(longitudeArgument.getRefDynamicSourceConfiguration()).isNull(); + } + +} 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 new file mode 100644 index 0000000000..91a47aac57 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java @@ -0,0 +1,161 @@ +/** + * 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.geofencing; + +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.AttributeScope; +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; + +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; + +@ExtendWith(MockitoExtension.class) +public class GeofencingCalculatedFieldConfigurationTest { + + @Test + void typeShouldBeGeofencing() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + assertThat(cfg.getType()).isEqualTo(CalculatedFieldType.GEOFENCING); + } + + @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() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class); + cfg.setEntityCoordinates(entityCoordinatesMock); + var zoneGroupConfiguration = mock(ZoneGroupConfiguration.class); + cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfiguration)); + + cfg.validate(); + + verify(entityCoordinatesMock).validate(); + verify(zoneGroupConfiguration).validate("someGroupName"); + } + + @Test + void validateShouldCallValidateOnEntityCoordinatesAndZoneGroupsWithoutAnyExceptions() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class); + cfg.setEntityCoordinates(entityCoordinatesMock); + var zoneGroupConfigurationA = mock(ZoneGroupConfiguration.class); + var zoneGroupConfigurationB = mock(ZoneGroupConfiguration.class); + + String zoneGroupAName = "zoneGroupA"; + String zoneGroupBName = "zoneGroupB"; + + cfg.setZoneGroups(Map.of("zoneGroupA", zoneGroupConfigurationA, "zoneGroupB", zoneGroupConfigurationB)); + + 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(); + cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY)); + cfg.setZoneGroups(Map.of("allowedZones", new ZoneGroupConfiguration("perimeter", GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false))); + + Map arguments = cfg.getArguments(); + + assertThat(arguments).isNotNull().hasSize(3); + assertThat(arguments).containsKeys(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, "allowedZones"); + + Argument latitudeArgument = arguments.get(ENTITY_ID_LATITUDE_ARGUMENT_KEY); + assertThat(latitudeArgument).isNotNull(); + assertThat(latitudeArgument.getRefDynamicSourceConfiguration()).isNull(); + assertThat(latitudeArgument.getRefEntityId()).isNull(); + assertThat(latitudeArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ArgumentType.TS_LATEST, null)); + + Argument longitudeArgument = arguments.get(ENTITY_ID_LONGITUDE_ARGUMENT_KEY); + assertThat(longitudeArgument).isNotNull(); + assertThat(longitudeArgument.getRefDynamicSourceConfiguration()).isNull(); + assertThat(longitudeArgument.getRefEntityId()).isNull(); + assertThat(longitudeArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, ArgumentType.TS_LATEST, null)); + + Argument allowedZonesArgument = arguments.get("allowedZones"); + assertThat(allowedZonesArgument).isNotNull(); + assertThat(allowedZonesArgument.getRefDynamicSourceConfiguration()).isNull(); + assertThat(allowedZonesArgument.getRefEntityId()).isNull(); + assertThat(allowedZonesArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("perimeter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + } + +} 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 new file mode 100644 index 0000000000..4eb822d93c --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java @@ -0,0 +1,123 @@ +/** + * 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.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.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.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; + +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.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS; + +public class ZoneGroupConfigurationTest { + + @ParameterizedTest + @ValueSource(strings = {EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY, EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY}) + void validateShouldThrowWhenUsedReservedEntityCoordinateNames(String name) { + var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + assertThatThrownBy(() -> zoneGroupConfiguration.validate(name)) + .isInstanceOf(IllegalArgumentException.class) + .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 + void validateShouldThrowWhenRelationCreationEnabledAndRelationTypeIsNullEmptyOrBlank(String relationType) { + var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true); + zoneGroupConfiguration.setRelationType(relationType); + assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Relation type must be specified for 'allowedZonesGroup' zone group!"); + } + + @Test + void validateShouldThrowWhenRelationCreationEnabledAndDirectionIsNull() { + var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true); + zoneGroupConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE); + zoneGroupConfiguration.setDirection(null); + assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Relation direction must be specified for 'allowedZonesGroup' zone group!"); + } + + @Test + void validateShouldDoesNotThrowAnyExceptionWhenRelationCreationDisabledAndConfigValid() { + var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + assertThatCode(() -> zoneGroupConfiguration.validate("allowedZonesGroup")).doesNotThrowAnyException(); + } + + @Test + void validateShouldDoesNotThrowAnyExceptionWhenRelationCreationEnabledAndConfigValid() { + var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true); + zoneGroupConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE); + zoneGroupConfiguration.setDirection(EntitySearchDirection.TO); + assertThatCode(() -> zoneGroupConfiguration.validate("allowedZonesGroup")).doesNotThrowAnyException(); + } + + @Test + void whenHasDynamicSourceCalled_shouldReturnTrueIfDynamicSourceConfigurationIsNotNull() { + var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + zoneGroupConfiguration.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration()); + assertThat(zoneGroupConfiguration.hasDynamicSource()).isTrue(); + } + + @Test + void whenHasDynamicSourceCalled_shouldReturnTrueIfDynamicSourceConfigurationIsNull() { + var zoneGroupConfiguration = mock(ZoneGroupConfiguration.class); + assertThat(zoneGroupConfiguration.getRefDynamicSourceConfiguration()).isNull(); + assertThat(zoneGroupConfiguration.hasDynamicSource()).isFalse(); + } + + + @Test + void validateToArgumentsMethodCallWithoutRefEntityId() { + var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + Argument zoneGroupArgument = zoneGroupConfiguration.toArgument(); + assertThat(zoneGroupArgument).isNotNull(); + assertThat(zoneGroupArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("perimeter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + } + +} 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 20043582d7..48b07af29b 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 @@ -147,7 +147,10 @@ public enum MsgType { /* CF Manager Actor -> CF Entity actor */ CF_ENTITY_TELEMETRY_MSG, CF_ENTITY_INIT_CF_MSG, - CF_ENTITY_DELETE_MSG; + CF_ENTITY_DELETE_MSG, + + CF_DYNAMIC_ARGUMENTS_REFRESH_MSG, + CF_ENTITY_DYNAMIC_ARGUMENTS_REFRESH_MSG; @Getter private final boolean ignoreOnStart; 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 be407bd3db..f5a07cf07e 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 @@ -1377,6 +1377,18 @@ public class ProtoUtils { return TbMsg.fromProto(queueName, getTbMsgProto(ruleEngineMsg), callback); } + public static TransportProtos.EntityIdProto toProto(EntityId entityId) { + return TransportProtos.EntityIdProto.newBuilder() + .setEntityIdMSB(getMsb(entityId)) + .setEntityIdLSB(getLsb(entityId)) + .setType(toProto(entityId.getEntityType())) + .build(); + } + + public static EntityId fromProto(TransportProtos.EntityIdProto entityIdProto) { + return EntityIdFactory.getByTypeAndUuid(fromProto(entityIdProto.getType()), new UUID(entityIdProto.getEntityIdMSB(), entityIdProto.getEntityIdLSB())); + } + private static boolean isNotNull(Object obj) { return obj != null; } diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 8372753df4..a05fdd5d36 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -82,6 +82,12 @@ enum ApiUsageRecordKeyProto { INACTIVE_DEVICES = 10; } +message EntityIdProto { + int64 entityIdMSB = 1; + int64 entityIdLSB = 2; + EntityTypeProto type = 4; +} + /** * Service Discovery Data Structures; */ @@ -896,11 +902,25 @@ message TsRollingArgumentProto { repeated TsDoubleValProto tsValue = 4; } +message GeofencingZoneProto { + EntityIdProto zoneId = 1; + int64 ts = 2; + string perimeterDefinition = 3; + int64 version = 4; + optional bool inside = 5; +} + +message GeofencingArgumentProto { + string argName = 1; + repeated GeofencingZoneProto zones = 2; +} + message CalculatedFieldStateProto { CalculatedFieldEntityCtxIdProto id = 1; string type = 2; repeated SingleValueArgumentProto singleValueArguments = 3; repeated TsRollingArgumentProto rollingValueArguments = 4; + repeated GeofencingArgumentProto geofencingArguments = 5; } //Used to report session state to tb-Service and persist this state in the cache on the tb-Service level. diff --git a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java index a46e489268..0f4733cafb 100644 --- a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java +++ b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; import org.thingsboard.common.util.JacksonUtil; @@ -43,6 +44,7 @@ import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKey; @@ -344,4 +346,24 @@ class ProtoUtilsTest { assertThat(toDeviceRpcRequestActorMsg.getMsg()).isEqualTo(request); } + @ParameterizedTest + @EnumSource(EntityType.class) + void testEntityIdProto_toProto_fromProto(EntityType entityType) { + UUID uuid = UUID.fromString("51a514d7-ea8f-496d-b567-f6e76f0f9b83"); + + EntityId original = EntityIdFactory.getByTypeAndUuid(entityType, uuid); + assertThat(original).isNotNull(); + + // toProto + TransportProtos.EntityIdProto proto = ProtoUtils.toProto(original); + assertThat(proto).isNotNull(); + assertThat(proto.getType().getNumber()).isEqualTo(entityType.getProtoNumber()); + assertThat(proto.getEntityIdMSB()).isEqualTo(uuid.getMostSignificantBits()); + assertThat(proto.getEntityIdLSB()).isEqualTo(uuid.getLeastSignificantBits()); + + // fromProto + EntityId restored = ProtoUtils.fromProto(proto); + assertThat(restored).isNotNull().isEqualTo(original); + } + } 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 f95b08195e..73a2183564 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 @@ -26,7 +26,8 @@ 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 = TbelCfTsRollingArg.class, name = "TS_ROLLING"), + @JsonSubTypes.Type(value = TbelCfTsGeofencingArg.class, name = "GEOFENCING_CF_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/TbelCfTsGeofencingArg.java new file mode 100644 index 0000000000..f1e8ec16db --- /dev/null +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsGeofencingArg.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.script.api.tbel; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class TbelCfTsGeofencingArg implements TbelCfArg { + + private final Object value; + + @JsonCreator + public TbelCfTsGeofencingArg(@JsonProperty("value") Object value) { + this.value = value; + } + + @Override + public String getType() { + return "GEOFENCING_CF_ARGUMENT_VALUE"; + } + + + @Override + public long memorySize() { + return OBJ_SIZE; + } + +} diff --git a/common/util/src/main/java/org/thingsboard/common/util/geo/CirclePerimeterDefinition.java b/common/util/src/main/java/org/thingsboard/common/util/geo/CirclePerimeterDefinition.java new file mode 100644 index 0000000000..4d5390a27a --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/geo/CirclePerimeterDefinition.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.common.util.geo; + +import lombok.Data; + +@Data +public class CirclePerimeterDefinition implements PerimeterDefinition { + + private final Double latitude; + private final Double longitude; + private final Double radius; + + @Override + public PerimeterType getType() { + return PerimeterType.CIRCLE; + } + + @Override + public boolean checkMatches(Coordinates entityCoordinates) { + Coordinates perimeterCoordinates = new Coordinates(latitude, longitude); + return radius > GeoUtil.distance(entityCoordinates, perimeterCoordinates, RangeUnit.METER); + } + +} diff --git a/common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinition.java b/common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinition.java new file mode 100644 index 0000000000..7c7f2cd1a3 --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinition.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.common.util.geo; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import java.io.Serializable; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonDeserialize(using = PerimeterDefinitionDeserializer.class) +@JsonSerialize(using = PerimeterDefinitionSerializer.class) +public interface PerimeterDefinition extends Serializable { + + @JsonIgnore + PerimeterType getType(); + + @JsonIgnore + boolean checkMatches(Coordinates entityCoordinates); +} diff --git a/common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinitionDeserializer.java b/common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinitionDeserializer.java new file mode 100644 index 0000000000..e60e314fe0 --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinitionDeserializer.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.common.util.geo; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.ObjectCodec; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; + +public class PerimeterDefinitionDeserializer extends JsonDeserializer { + + @Override + public PerimeterDefinition deserialize(JsonParser p, DeserializationContext ctx) throws IOException { + ObjectCodec codec = p.getCodec(); + JsonNode node = codec.readTree(p); + + if (node.isObject()) { + double latitude = node.get("latitude").asDouble(); + double longitude = node.get("longitude").asDouble(); + double radius = node.get("radius").asDouble(); + return new CirclePerimeterDefinition(latitude, longitude, radius); + } + if (node.isArray()) { + ObjectMapper mapper = (ObjectMapper) p.getCodec(); + String polygonStrDefinition = mapper.writeValueAsString(node); + return new PolygonPerimeterDefinition(polygonStrDefinition); + } + throw new IOException("Failed to deserialize PerimeterDefinition from node: " + node); + } + +} diff --git a/common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinitionSerializer.java b/common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinitionSerializer.java new file mode 100644 index 0000000000..d27aafecc0 --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinitionSerializer.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.common.util.geo; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import org.thingsboard.server.common.data.StringUtils; + +import java.io.IOException; + +public class PerimeterDefinitionSerializer extends JsonSerializer { + + @Override + public void serialize(PerimeterDefinition value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + if (value instanceof CirclePerimeterDefinition c) { + gen.writeStartObject(); + gen.writeNumberField("latitude", c.getLatitude()); + gen.writeNumberField("longitude", c.getLongitude()); + gen.writeNumberField("radius", c.getRadius()); + gen.writeEndObject(); + return; + } + if (value instanceof PolygonPerimeterDefinition p) { + String raw = p.getPolygonDefinition(); + if (StringUtils.isBlank(raw)) { + throw new IOException("Failed to serialize PolygonPerimeterDefinition with blank: " + value); + } + ObjectMapper mapper = (ObjectMapper) gen.getCodec(); + gen.writeTree(mapper.readTree(raw)); + return; + } + throw new IOException("Failed to serialize PerimeterDefinition from value: " + value); + } +} diff --git a/common/util/src/main/java/org/thingsboard/common/util/geo/PolygonPerimeterDefinition.java b/common/util/src/main/java/org/thingsboard/common/util/geo/PolygonPerimeterDefinition.java new file mode 100644 index 0000000000..2d8ca6ef56 --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/geo/PolygonPerimeterDefinition.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.common.util.geo; + +import lombok.Data; + +@Data +public class PolygonPerimeterDefinition implements PerimeterDefinition { + + private final String polygonDefinition; + + @Override + public PerimeterType getType() { + return PerimeterType.POLYGON; + } + + @Override + public boolean checkMatches(Coordinates entityCoordinates) { + return GeoUtil.contains(polygonDefinition, entityCoordinates); + } + +} diff --git a/common/util/src/test/java/org/thingsboard/common/util/geo/PerimeterDefinitionDeserializerTest.java b/common/util/src/test/java/org/thingsboard/common/util/geo/PerimeterDefinitionDeserializerTest.java new file mode 100644 index 0000000000..5c00847861 --- /dev/null +++ b/common/util/src/test/java/org/thingsboard/common/util/geo/PerimeterDefinitionDeserializerTest.java @@ -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. + */ +package org.thingsboard.common.util.geo; + +import org.junit.jupiter.api.Test; +import org.thingsboard.common.util.JacksonUtil; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PerimeterDefinitionDeserializerTest { + + @Test + void shouldDeserializeCircle() { + String json = """ + {"latitude":50.45,"longitude":30.52,"radius":100.0}"""; + + PerimeterDefinition def = JacksonUtil.fromString(json, PerimeterDefinition.class); + + assertThat(def).isNotNull().isInstanceOf(CirclePerimeterDefinition.class); + + CirclePerimeterDefinition circle = (CirclePerimeterDefinition) def; + assertThat(circle.getLatitude()).isEqualTo(50.45); + assertThat(circle.getLongitude()).isEqualTo(30.52); + assertThat(circle.getRadius()).isEqualTo(100.0); + } + + @Test + void shouldDeserializePolygon() { + String json = "[[50.45,30.52],[50.46,30.53],[50.44,30.54]]"; + + PerimeterDefinition def = JacksonUtil.fromString(json, PerimeterDefinition.class); + + assertThat(def).isInstanceOf(PolygonPerimeterDefinition.class); + PolygonPerimeterDefinition poly = (PolygonPerimeterDefinition) def; + assertThat(poly.getPolygonDefinition()).isEqualTo(json); + } +} diff --git a/common/util/src/test/java/org/thingsboard/common/util/geo/PerimeterDefinitionSerializerTest.java b/common/util/src/test/java/org/thingsboard/common/util/geo/PerimeterDefinitionSerializerTest.java new file mode 100644 index 0000000000..d316d1c398 --- /dev/null +++ b/common/util/src/test/java/org/thingsboard/common/util/geo/PerimeterDefinitionSerializerTest.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.common.util.geo; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.Test; +import org.thingsboard.common.util.JacksonUtil; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PerimeterDefinitionSerializerTest { + + @Test + void shouldSerializeCircle() { + PerimeterDefinition circle = new CirclePerimeterDefinition(50.45, 30.52, 120.0); + + String json = JacksonUtil.writeValueAsString(circle); + + JsonNode actual = JacksonUtil.toJsonNode(json); + assertThat(actual.get("latitude").asDouble()).isEqualTo(50.45); + assertThat(actual.get("longitude").asDouble()).isEqualTo(30.52); + assertThat(actual.get("radius").asDouble()).isEqualTo(120.0); + } + + @Test + void shouldSerializePolygon() throws Exception { + String rawArray = "[[50.45,30.52],[50.46,30.53],[50.44,30.54]]"; + PerimeterDefinition polygon = new PolygonPerimeterDefinition(rawArray); + + String json = JacksonUtil.writeValueAsString(polygon); + + JsonNode actual = JacksonUtil.toJsonNode(json); + JsonNode expected = JacksonUtil.toJsonNode(rawArray); + assertThat(actual).isEqualTo(expected); + assertThat(actual.isArray()).isTrue(); + assertThat(actual.size()).isEqualTo(3); + } + +} 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 7560c7fb76..a34e968acc 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 @@ -81,7 +81,7 @@ public abstract class AbstractEntityService { @Autowired @Lazy - private TbTenantProfileCache tbTenantProfileCache; + protected TbTenantProfileCache tbTenantProfileCache; @Value("${debug.settings.default_duration:15}") private int defaultDebugDurationMinutes; 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 c9c7af1a89..05b782c26c 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,7 +18,9 @@ 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.id.EntityId; +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.ScheduledUpdateSupportedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.dao.cf.CalculatedFieldDao; @@ -26,6 +28,9 @@ import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.usagerecord.ApiLimitService; +import java.util.Map; +import java.util.stream.Collectors; + @Component public class CalculatedFieldDataValidator extends DataValidator { @@ -36,10 +41,22 @@ public class CalculatedFieldDataValidator extends DataValidator private ApiLimitService apiLimitService; @Override - protected void validateCreate(TenantId tenantId, CalculatedField calculatedField) { - validateNumberOfCFsPerEntity(tenantId, calculatedField.getEntityId()); + protected void validateDataImpl(TenantId tenantId, CalculatedField calculatedField) { validateNumberOfArgumentsPerCF(tenantId, calculatedField); - validateArgumentNames(calculatedField); + validateCalculatedFieldConfiguration(calculatedField); + validateSchedulingConfiguration(tenantId, calculatedField); + validateRelationQuerySourceArguments(tenantId, calculatedField); + } + + @Override + protected void validateCreate(TenantId tenantId, CalculatedField calculatedField) { + long maxCFsPerEntity = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxCalculatedFieldsPerEntity); + if (maxCFsPerEntity <= 0) { + return; + } + if (calculatedFieldDao.countCFByEntityId(tenantId, calculatedField.getEntityId()) >= maxCFsPerEntity) { + throw new DataValidationException("Calculated fields per entity limit reached!"); + } } @Override @@ -48,34 +65,56 @@ public class CalculatedFieldDataValidator extends DataValidator if (old == null) { throw new DataValidationException("Can't update non existing calculated field!"); } - validateNumberOfArgumentsPerCF(tenantId, calculatedField); - validateArgumentNames(calculatedField); return old; } - private void validateNumberOfCFsPerEntity(TenantId tenantId, EntityId entityId) { - long maxCFsPerEntity = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxCalculatedFieldsPerEntity); - if (maxCFsPerEntity <= 0) { + private void validateNumberOfArgumentsPerCF(TenantId tenantId, CalculatedField calculatedField) { + if (!(calculatedField instanceof ArgumentsBasedCalculatedFieldConfiguration argumentsBasedCfg)) { return; } - if (calculatedFieldDao.countCFByEntityId(tenantId, entityId) >= maxCFsPerEntity) { - throw new DataValidationException("Calculated fields per entity limit reached!"); - } - } - - private void validateNumberOfArgumentsPerCF(TenantId tenantId, CalculatedField calculatedField) { long maxArgumentsPerCF = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxArgumentsPerCF); if (maxArgumentsPerCF <= 0) { return; } - if (calculatedField.getConfiguration().getArguments().size() > maxArgumentsPerCF) { + if (argumentsBasedCfg.getArguments().size() > maxArgumentsPerCF) { throw new DataValidationException("Calculated field arguments limit reached!"); } } - private void validateArgumentNames(CalculatedField calculatedField) { - if (calculatedField.getConfiguration().getArguments().containsKey("ctx")) { - throw new DataValidationException("Argument name 'ctx' is reserved and cannot be used."); + private void validateCalculatedFieldConfiguration(CalculatedField calculatedField) { + wrapAsDataValidation(calculatedField.getConfiguration()::validate); + } + + private void validateSchedulingConfiguration(TenantId tenantId, CalculatedField calculatedField) { + if (!(calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledUpdateCfg) + || !scheduledUpdateCfg.isScheduledUpdateEnabled()) { + return; + } + long minAllowedScheduledUpdateInterval = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMinAllowedScheduledUpdateIntervalInSecForCF); + wrapAsDataValidation(() -> scheduledUpdateCfg.validate(minAllowedScheduledUpdateInterval)); + } + + private void validateRelationQuerySourceArguments(TenantId tenantId, CalculatedField calculatedField) { + if (!(calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration argumentsBasedCfg)) { + return; + } + Map relationQueryBasedArguments = argumentsBasedCfg.getArguments().entrySet() + .stream() + .filter(entry -> entry.getValue().hasDynamicSource()) + .collect(Collectors.toMap(Map.Entry::getKey, entry -> (RelationQueryDynamicSourceConfiguration) entry.getValue().getRefDynamicSourceConfiguration())); + if (relationQueryBasedArguments.isEmpty()) { + return; + } + int maxRelationLevel = (int) apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxRelationLevelPerCfArgument); + relationQueryBasedArguments.forEach((argumentName, relationQueryDynamicSourceConfiguration) -> + wrapAsDataValidation(() -> relationQueryDynamicSourceConfiguration.validateMaxRelationLevel(argumentName, maxRelationLevel))); + } + + private static void wrapAsDataValidation(Runnable validation) { + try { + validation.run(); + } catch (IllegalArgumentException e) { + throw new DataValidationException(e.getMessage(), e); } } 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 2985aa7620..49bdcca0fb 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 @@ -31,18 +31,24 @@ 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.SimpleCalculatedFieldConfiguration; -import org.thingsboard.server.common.data.id.CalculatedFieldId; +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.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; 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.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.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS; @DaoSqlTest public class CalculatedFieldServiceTest extends AbstractServiceTest { @@ -51,6 +57,8 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { private CalculatedFieldService calculatedFieldService; @Autowired private DeviceService deviceService; + @Autowired + private TbTenantProfileCache tbTenantProfileCache; private ListeningExecutorService executor; @@ -90,6 +98,182 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { calculatedFieldService.deleteCalculatedField(tenantId, savedCalculatedField.getId()); } + @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() { + // 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(1); + dynamicSourceConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE); + zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration); + cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration)); + + // Enable scheduling with an interval below tenant min + 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); + + assertThatThrownBy(() -> calculatedFieldService.save(cf)) + .isInstanceOf(DataValidationException.class) + .hasCauseInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Scheduled update interval is less than configured " + + "minimum allowed interval in tenant profile: "); + } + + @Test + public void testSaveGeofencingCalculatedField_shouldThrowWhenRelationLevelIsGreaterThanMaxAllowedRelationLevelInTenantProfile() { + // 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); + zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration); + cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration)); + + // 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); + + assertThatThrownBy(() -> calculatedFieldService.save(cf)) + .isInstanceOf(DataValidationException.class) + .hasCauseInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Max relation level is greater than configured maximum allowed relation level in tenant profile"); + } + + @Test + public void testSaveGeofencingCalculatedField_shouldUseScheduledIntervalFromConfig() { + // 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(1); + dynamicSourceConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE); + zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration); + cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration)); + + // Get tenant profile min. + int min = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedScheduledUpdateIntervalInSecForCF(); + + + // Enable scheduling with an interval greater than tenant min + int valueFromConfig = min + 100; + 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 no 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 clamped up to tenant profile min (or stays >= original if already >= min) + int savedInterval = geofencingCalculatedFieldConfiguration.getScheduledUpdateInterval(); + assertThat(savedInterval).isEqualTo(valueFromConfig); + + calculatedFieldService.deleteCalculatedField(tenantId, saved.getId()); + } + @Test public void testSaveCalculatedFieldWithExistingName() { Device device = createTestDevice(); 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 102f4b82ea..0a15d6e8aa 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 @@ -16,6 +16,7 @@ package org.thingsboard.server.msa; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.restassured.RestAssured; import io.restassured.common.mapper.TypeRef; @@ -231,9 +232,9 @@ public class TestRestClient { .statusCode(anyOf(is(HTTP_OK), is(HTTP_NOT_FOUND))); } - public ValidatableResponse postTelemetryAttribute(EntityId entityId, String scope, JsonNode attribute) { + public ValidatableResponse postTelemetryAttribute(EntityId entityId, AttributeScope scope, JsonNode attribute) { return given().spec(requestSpec).body(attribute) - .post("/api/plugins/telemetry/{entityType}/{entityId}/attributes/{scope}", entityId.getEntityType(), entityId.getId(), scope) + .post("/api/plugins/telemetry/{entityType}/{entityId}/attributes/{scope}", entityId.getEntityType(), entityId.getId(), scope.name()) .then() .statusCode(HTTP_OK); } @@ -256,13 +257,13 @@ public class TestRestClient { .as(JsonNode.class); } - public JsonNode getAttributes(EntityId entityId, AttributeScope scope, String keys) { + public ArrayNode getAttributes(EntityId entityId, AttributeScope scope, String keys) { return given().spec(requestSpec) .get("/api/plugins/telemetry/{entityType}/{entityId}/values/attributes/{scope}?keys={keys}", entityId.getEntityType(), entityId.getId(), scope, keys) .then() .statusCode(HTTP_OK) .extract() - .as(JsonNode.class); + .as(ArrayNode.class); } public JsonNode getLatestTelemetry(EntityId entityId) { @@ -367,6 +368,16 @@ public class TestRestClient { }); } + public EntityRelation postEntityRelation(EntityRelation entityRelation) { + return given().spec(requestSpec) + .body(entityRelation) + .post("/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 456d320e11..7f2dfba937 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 @@ -16,14 +16,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.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.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.asset.Asset; @@ -34,8 +33,12 @@ 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.RelationQueryDynamicSourceConfiguration; 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; +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.debug.DebugSettings; import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration; import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; @@ -45,14 +48,19 @@ 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.id.UserId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.msa.AbstractContainerTest; import org.thingsboard.server.msa.ui.utils.EntityPrototypes; +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.thingsboard.server.common.data.AttributeScope.SERVER_SCOPE; +import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS; import static org.thingsboard.server.msa.ui.utils.EntityPrototypes.defaultAssetProfile; import static org.thingsboard.server.msa.ui.utils.EntityPrototypes.defaultDeviceProfile; import static org.thingsboard.server.msa.ui.utils.EntityPrototypes.defaultTenantAdmin; @@ -65,17 +73,17 @@ public class CalculatedFieldTest extends AbstractContainerTest { private final String deviceToken = "zmzURIVRsq3lvnTP2XBE"; private final String exampleScript = "var avgTemperature = temperature.mean(); // Get average temperature\n" + - " var temperatureK = (avgTemperature - 32) * (5 / 9) + 273.15; // Convert Fahrenheit to Kelvin\n" + - "\n" + - " // Estimate air pressure based on altitude\n" + - " var pressure = 101325 * Math.pow((1 - 2.25577e-5 * altitude), 5.25588);\n" + - "\n" + - " // Air density formula\n" + - " var airDensity = pressure / (287.05 * temperatureK);\n" + - "\n" + - " return {\n" + - " \"airDensity\": toFixed(airDensity, 2)\n" + - " };"; + " var temperatureK = (avgTemperature - 32) * (5 / 9) + 273.15; // Convert Fahrenheit to Kelvin\n" + + "\n" + + " // Estimate air pressure based on altitude\n" + + " var pressure = 101325 * Math.pow((1 - 2.25577e-5 * altitude), 5.25588);\n" + + "\n" + + " // Air density formula\n" + + " var airDensity = pressure / (287.05 * temperatureK);\n" + + "\n" + + " return {\n" + + " \"airDensity\": toFixed(airDensity, 2)\n" + + " };"; private TenantId tenantId; private UserId tenantAdminId; @@ -98,13 +106,13 @@ public class CalculatedFieldTest extends AbstractContainerTest { asset = testRestClient.postAsset(createAsset("Asset 1", assetProfileId)); testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}")); - testRestClient.postTelemetryAttribute(device.getId(), DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"deviceTemperature\":40}")); + testRestClient.postTelemetryAttribute(device.getId(), SERVER_SCOPE, JacksonUtil.toJsonNode("{\"deviceTemperature\":40}")); testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperatureInF\":72.32}")); testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperatureInF\":72.86}")); testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperatureInF\":73.58}")); - testRestClient.postTelemetryAttribute(asset.getId(), DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"altitude\":1035}")); + testRestClient.postTelemetryAttribute(asset.getId(), SERVER_SCOPE, JacksonUtil.toJsonNode("{\"altitude\":1035}")); } @BeforeMethod @@ -146,9 +154,10 @@ public class CalculatedFieldTest extends AbstractContainerTest { testRestClient.getAndSetUserToken(tenantAdminId); CalculatedField savedCalculatedField = createSimpleCalculatedField(); + assertThat(savedCalculatedField.getConfiguration() instanceof SimpleCalculatedFieldConfiguration).isTrue(); - Argument savedArgument = savedCalculatedField.getConfiguration().getArguments().get("T"); - savedArgument.setRefEntityKey(new ReferencedEntityKey("deviceTemperature", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + Argument savedArgument = ((SimpleCalculatedFieldConfiguration) savedCalculatedField.getConfiguration()).getArguments().get("T"); + savedArgument.setRefEntityKey(new ReferencedEntityKey("deviceTemperature", ArgumentType.ATTRIBUTE, SERVER_SCOPE)); testRestClient.postCalculatedField(savedCalculatedField); await().alias("update CF argument -> perform calculation with new argument").atMost(TIMEOUT, TimeUnit.SECONDS) @@ -172,14 +181,14 @@ public class CalculatedFieldTest extends AbstractContainerTest { Output savedOutput = savedCalculatedField.getConfiguration().getOutput(); savedOutput.setType(OutputType.ATTRIBUTES); - savedOutput.setScope(AttributeScope.SERVER_SCOPE); + savedOutput.setScope(SERVER_SCOPE); savedOutput.setName("temperatureF"); testRestClient.postCalculatedField(savedCalculatedField); await().alias("update CF output -> perform calculation with updated output").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) .untilAsserted(() -> { - JsonNode temperatureF = testRestClient.getAttributes(device.getId(), AttributeScope.SERVER_SCOPE, "temperatureF"); + ArrayNode temperatureF = testRestClient.getAttributes(device.getId(), SERVER_SCOPE, "temperatureF"); assertThat(temperatureF).isNotNull(); assertThat(temperatureF.get(0)).isNotNull(); assertThat(temperatureF.get(0).get("value").asText()).isEqualTo("77.0"); @@ -194,9 +203,10 @@ public class CalculatedFieldTest extends AbstractContainerTest { testRestClient.getAndSetUserToken(tenantAdminId); CalculatedField savedCalculatedField = createSimpleCalculatedField(); + assertThat(savedCalculatedField.getConfiguration() instanceof SimpleCalculatedFieldConfiguration).isTrue(); savedCalculatedField.setName("F to C"); - savedCalculatedField.getConfiguration().setExpression("(T - 32) / 1.8"); + ((SimpleCalculatedFieldConfiguration) savedCalculatedField.getConfiguration()).setExpression("(T - 32) / 1.8"); testRestClient.postCalculatedField(savedCalculatedField); await().alias("update CF expression -> perform calculation with new expression").atMost(TIMEOUT, TimeUnit.SECONDS) @@ -305,7 +315,7 @@ public class CalculatedFieldTest extends AbstractContainerTest { assertThat(airDensity.get("airDensity").get(0).get("value").asText()).isEqualTo("1.05"); }); - testRestClient.postTelemetryAttribute(asset.getId(), DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"altitude\":1531}")); + testRestClient.postTelemetryAttribute(asset.getId(), SERVER_SCOPE, JacksonUtil.toJsonNode("{\"altitude\":1531}")); await().alias("create CF -> update telemetry for common entity").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -319,6 +329,100 @@ public class CalculatedFieldTest extends AbstractContainerTest { testRestClient.deleteCalculatedFieldIfExists(savedCalculatedField.getId()); } + @Test + public void testPerformSerialsOfCalculationsForGeofencingType() { + // login tenant admin + testRestClient.getAndSetUserToken(tenantAdminId); + + // Device and initial coords (inside Allowed, outside Restricted) + String deviceToken = "geoDeviceTokenA"; + Device device = testRestClient.postDevice(deviceToken, createDevice("GF Device", deviceProfileId)); + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"latitude\":50.4730,\"longitude\":30.5050}")); + + // Create zones + Asset allowed = testRestClient.postAsset(createAsset("Allowed Zone", null)); + testRestClient.postTelemetryAttribute(allowed.getId(), SERVER_SCOPE, + JacksonUtil.toJsonNode("{\"zone\":[[50.472000,30.504000],[50.472000,30.506000],[50.474000,30.506000],[50.474000,30.504000]]}")); + + Asset restricted = testRestClient.postAsset(createAsset("Restricted Zone", null)); + testRestClient.postTelemetryAttribute(restricted.getId(), SERVER_SCOPE, + JacksonUtil.toJsonNode("{\"zone\":[[50.475000,30.510000],[50.475000,30.512000],[50.477000,30.512000],[50.477000,30.510000]]}")); + + // Relations FROM device + testRestClient.postEntityRelation(new EntityRelation(device.getId(), allowed.getId(), "AllowedZone")); + testRestClient.postEntityRelation(new EntityRelation(device.getId(), restricted.getId(), "RestrictedZone")); + + // Build CF: GEOFENCING -> attributes output + CalculatedField cf = new CalculatedField(); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.GEOFENCING); + cf.setName("Geofencing CF"); + cf.setDebugSettings(DebugSettings.off()); + + GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration(); + + EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude"); + cfg.setEntityCoordinates(entityCoordinates); + + // 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"); + 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"); + restrictedZoneGroupConfiguration.setRefDynamicSourceConfiguration(restrictedDynamicSourceConfiguration); + + cfg.setZoneGroups(Map.of("allowedZones", allowedZoneGroupConfiguration, "restrictedZones", restrictedZoneGroupConfiguration)); + + Output out = new Output(); + out.setType(OutputType.ATTRIBUTES); + out.setScope(SERVER_SCOPE); + cfg.setOutput(out); + cf.setConfiguration(cfg); + + CalculatedField saved = testRestClient.postCalculatedField(cf); + + // Initial ENTERED/INSIDE and OUTSIDE + await().alias("initial geofencing evaluation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs = testRestClient.getAttributes(device.getId(), SERVER_SCOPE, + "allowedZonesEvent,allowedZonesStatus,restrictedZonesStatus"); + assertThat(attrs).isNotNull().hasSize(3); + Map m = kv(attrs); + assertThat(m).containsEntry("allowedZonesEvent", "ENTERED") + .containsEntry("allowedZonesStatus", "INSIDE") + .containsEntry("restrictedZonesStatus", "OUTSIDE"); + }); + + // Move device into Restricted zone -> expect LEFT/ENTERED and statuses flipped + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}")); + + await().alias("transition after movement").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs = testRestClient.getAttributes(device.getId(), SERVER_SCOPE, + "allowedZonesEvent,allowedZonesStatus,restrictedZonesEvent,restrictedZonesStatus"); + assertThat(attrs).isNotNull().hasSize(4); + Map m = kv(attrs); + assertThat(m).containsEntry("allowedZonesEvent", "LEFT") + .containsEntry("allowedZonesStatus", "OUTSIDE") + .containsEntry("restrictedZonesEvent", "ENTERED") + .containsEntry("restrictedZonesStatus", "INSIDE"); + }); + + testRestClient.deleteCalculatedFieldIfExists(saved.getId()); + } + private CalculatedField createSimpleCalculatedField() { return createSimpleCalculatedField(device.getId()); } @@ -366,7 +470,7 @@ public class CalculatedFieldTest extends AbstractContainerTest { Argument argument1 = new Argument(); argument1.setRefEntityId(refEntityId); - ReferencedEntityKey refEntityKey1 = new ReferencedEntityKey("altitude", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE); + ReferencedEntityKey refEntityKey1 = new ReferencedEntityKey("altitude", ArgumentType.ATTRIBUTE, SERVER_SCOPE); argument1.setRefEntityKey(refEntityKey1); Argument argument2 = new Argument(); ReferencedEntityKey refEntityKey2 = new ReferencedEntityKey("temperatureInF", ArgumentType.TS_ROLLING, null); @@ -406,4 +510,12 @@ public class CalculatedFieldTest extends AbstractContainerTest { return asset; } + private static Map kv(ArrayNode attrs) { + Map m = new HashMap<>(); + for (JsonNode n : attrs) { + m.put(n.get("key").asText(), n.get("value").asText()); + } + return m; + } + } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java index 4e28340f9d..306ca5adf4 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java @@ -35,8 +35,7 @@ import java.util.Arrays; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; -import static org.thingsboard.server.common.data.DataConstants.DEVICE; -import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE; +import static org.thingsboard.server.common.data.AttributeScope.SHARED_SCOPE; import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevicePrototype; @DisableUIListeners diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java index eb30ef8b9c..3e580379df 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java @@ -81,7 +81,7 @@ import java.util.concurrent.TimeoutException; import static org.assertj.core.api.Assertions.assertThat; import static org.testng.Assert.assertNotNull; import static org.testng.Assert.fail; -import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE; +import static org.thingsboard.server.common.data.AttributeScope.SHARED_SCOPE; import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevicePrototype; @DisableUIListeners @@ -577,7 +577,7 @@ public class MqttClientTest extends AbstractContainerTest { Awaitility .await() .alias("Check device disconnect.") - .atMost(TIMEOUT*timeoutMultiplier, TimeUnit.SECONDS) + .atMost(TIMEOUT * timeoutMultiplier, TimeUnit.SECONDS) .until(() -> !returnCodeByteValue.isEmpty()); assertThat(returnCodeByteValueSecondClient).isEmpty(); diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java index fd9d4f557d..5265bf9cc6 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java @@ -64,7 +64,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; -import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE; +import static org.thingsboard.server.common.data.AttributeScope.SHARED_SCOPE; import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultGatewayPrototype; @DisableUIListeners 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 6a806e7bc1..fc541ce4e4 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 @@ -16,9 +16,7 @@ package org.thingsboard.rule.engine.action; 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,18 +29,21 @@ import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -@Slf4j +import static com.google.common.util.concurrent.Futures.immediateFuture; +import static com.google.common.util.concurrent.Futures.transform; +import static com.google.common.util.concurrent.Futures.transformAsync; + @RuleNode( type = ComponentType.ACTION, name = "clear alarm", relationTypes = {"Cleared", "False"}, configClazz = TbClearAlarmNodeConfiguration.class, nodeDescription = "Clear Alarm", - nodeDetails = - "Details - JS function that creates JSON object based on incoming message. This object will be added into Alarm.details field.\n" + - "Node output:\n" + - "If alarm was not cleared, original message is returned. Otherwise new Message returned with type 'ALARM', Alarm object in 'msg' property and 'metadata' will contains 'isClearedAlarm' property. " + - "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;.", + nodeDetails = """ + Details - JS function that creates JSON object based on incoming message. This object will be added into Alarm.details field. + Node output: + If alarm was not cleared, original message is returned. Otherwise new Message returned with type 'ALARM', Alarm object in 'msg' property and 'metadata' will contains 'isClearedAlarm' property. + 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" ) @@ -55,22 +56,26 @@ public class TbClearAlarmNode extends TbAbstractAlarmNode processAlarm(TbContext ctx, TbMsg msg) { - String alarmType = TbNodeUtils.processPattern(this.config.getAlarmType(), msg); - Alarm alarm; - if (msg.getOriginator().getEntityType().equals(EntityType.ALARM)) { - alarm = ctx.getAlarmService().findAlarmById(ctx.getTenantId(), new AlarmId(msg.getOriginator().getId())); + String alarmType = TbNodeUtils.processPattern(config.getAlarmType(), msg); + + ListenableFuture alarmFuture; + if (msg.getOriginator().getEntityType() == EntityType.ALARM) { + alarmFuture = ctx.getAlarmService().findAlarmByIdAsync(ctx.getTenantId(), new AlarmId(msg.getOriginator().getId())); } else { - alarm = ctx.getAlarmService().findLatestActiveByOriginatorAndType(ctx.getTenantId(), msg.getOriginator(), alarmType); + alarmFuture = ctx.getAlarmService().findLatestActiveByOriginatorAndTypeAsync(ctx.getTenantId(), msg.getOriginator(), alarmType); } - if (alarm != null && !alarm.getStatus().isCleared()) { - return clearAlarm(ctx, msg, alarm); - } - return Futures.immediateFuture(new TbAlarmResult(false, false, false, null)); + + return transformAsync(alarmFuture, alarm -> { + if (alarm != null && !alarm.getStatus().isCleared()) { + return clearAlarmAsync(ctx, msg, alarm); + } + return immediateFuture(new TbAlarmResult(false, false, false, null)); + }, ctx.getDbCallbackExecutor()); } - private ListenableFuture clearAlarm(TbContext ctx, TbMsg msg, Alarm alarm) { + private ListenableFuture clearAlarmAsync(TbContext ctx, TbMsg msg, Alarm alarm) { ListenableFuture asyncDetails = buildAlarmDetails(msg, alarm.getDetails()); - return Futures.transform(asyncDetails, details -> { + return transform(asyncDetails, details -> { AlarmApiCallResult result = ctx.getAlarmService().clearAlarm(ctx.getTenantId(), alarm.getId(), System.currentTimeMillis(), details); if (result.isSuccessful()) { return new TbAlarmResult(false, false, result.isCleared(), result.getAlarm()); @@ -79,4 +84,5 @@ public class TbClearAlarmNode extends TbAbstractAlarmNode fieldsMap; @Override @@ -95,31 +87,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 +160,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 +201,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 +231,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/test/java/org/thingsboard/rule/engine/action/TbClearAlarmNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbClearAlarmNodeTest.java index ad2c6100bf..ccf950b80c 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbClearAlarmNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbClearAlarmNodeTest.java @@ -17,7 +17,7 @@ package org.thingsboard.rule.engine.action; import com.datastax.oss.driver.api.core.uuid.Uuids; import com.fasterxml.jackson.databind.JsonNode; -import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.FluentFuture; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -49,6 +49,7 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import java.util.function.Consumer; +import static com.google.common.util.concurrent.Futures.immediateFuture; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; @@ -112,8 +113,8 @@ class TbClearAlarmNodeTest { .endTs(oldEndDate) .build(); - when(alarmDetailsScriptMock.executeJsonAsync(msg)).thenReturn(Futures.immediateFuture(null)); - when(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, "SomeType")).thenReturn(activeAlarm); + when(alarmDetailsScriptMock.executeJsonAsync(msg)).thenReturn(immediateFuture(null)); + when(alarmServiceMock.findLatestActiveByOriginatorAndTypeAsync(tenantId, msgOriginator, "SomeType")).thenReturn(FluentFuture.from(immediateFuture(activeAlarm))); when(alarmServiceMock.clearAlarm(eq(activeAlarm.getTenantId()), eq(activeAlarm.getId()), anyLong(), nullable(JsonNode.class))) .thenReturn(AlarmApiCallResult.builder() .successful(true) @@ -172,8 +173,8 @@ class TbClearAlarmNodeTest { .build(); expectedAlarm.setId(id); - when(alarmDetailsScriptMock.executeJsonAsync(msg)).thenReturn(Futures.immediateFuture(null)); - when(alarmServiceMock.findAlarmById(tenantId, id)).thenReturn(activeAlarm); + when(alarmDetailsScriptMock.executeJsonAsync(msg)).thenReturn(immediateFuture(null)); + when(alarmServiceMock.findAlarmByIdAsync(tenantId, id)).thenReturn(immediateFuture(activeAlarm)); when(alarmServiceMock.clearAlarm(eq(activeAlarm.getTenantId()), eq(activeAlarm.getId()), anyLong(), nullable(JsonNode.class))) .thenReturn(AlarmApiCallResult.builder() .successful(true) diff --git a/ui-ngx/src/app/core/auth/auth.models.ts b/ui-ngx/src/app/core/auth/auth.models.ts index 142d845cf4..d6612f2427 100644 --- a/ui-ngx/src/app/core/auth/auth.models.ts +++ b/ui-ngx/src/app/core/auth/auth.models.ts @@ -31,6 +31,8 @@ export interface SysParamsState { maxDebugModeDurationMinutes: number; maxDataPointsPerRollingArg: number; maxArgumentsPerCF: number; + minAllowedScheduledUpdateIntervalInSecForCF: number; + maxRelationLevelPerCfArgument: number; ruleChainDebugPerTenantLimitsConfiguration?: string; calculatedFieldDebugPerTenantLimitsConfiguration?: string; trendzSettings: TrendzSettings; diff --git a/ui-ngx/src/app/core/auth/auth.reducer.ts b/ui-ngx/src/app/core/auth/auth.reducer.ts index 785f40ce3c..8cfbc04197 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -33,6 +33,8 @@ const emptyUserAuthState: AuthPayload = { mobileQrEnabled: false, maxResourceSize: 0, maxArgumentsPerCF: 0, + minAllowedScheduledUpdateIntervalInSecForCF: 0, + maxRelationLevelPerCfArgument: 0, maxDataPointsPerRollingArg: 0, maxDebugModeDurationMinutes: 0, userSettings: initialUserSettings, diff --git a/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html b/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html index 453f9f6217..cd87bde8f2 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html @@ -29,8 +29,8 @@
-
- +
+ attribute.key 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 07f8f0293c..5f4b894448 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 @@ -113,16 +113,16 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig { const expressionLabel = this.getExpressionLabel(entity); - return expressionLabel.length < 45 ? expressionLabel : `${expressionLabel.substring(0, 44)}…`; + return expressionLabel?.length < 45 ? expressionLabel : `${expressionLabel.substring(0, 44)}…`; } expressionColumn.cellTooltipFunction = entity => { const expressionLabel = this.getExpressionLabel(entity); - return expressionLabel.length < 45 ? null : expressionLabel + return expressionLabel?.length < 45 ? null : expressionLabel }; this.columns.push(new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px')); this.columns.push(new EntityTableColumn('name', 'common.name', '33%')); - this.columns.push(new EntityTableColumn('type', 'common.type', '50px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type)))); + this.columns.push(new EntityTableColumn('type', 'common.type', '70px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type)))); this.columns.push(expressionColumn); this.cellActionDescriptors.push( @@ -159,7 +159,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig { - const arg = calculatedField.configuration.arguments[key]; - acc[key] = arg.refEntityId?.entityType === ArgumentEntityType.Tenant - ? { ...arg, refEntityId: { id: this.tenantId, entityType: ArgumentEntityType.Tenant } } - : arg; - return acc; - }, {}); + 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; } 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/arguments-table/calculated-field-arguments-table.component.html index 4086b3e7b0..8cf040538c 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/arguments-table/calculated-field-arguments-table.component.html @@ -59,7 +59,7 @@
@if (argument.refEntityId?.id && argument.refEntityId?.entityType !== ArgumentEntityType.Tenant) { - {{ entityNameMap.get(argument.refEntityId.id) ?? '' }} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html index 7b69d26a60..4b12d9f31a 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html @@ -63,76 +63,116 @@
-
-
{{ 'calculated-fields.arguments' | translate }}
- -
-
-
- {{ (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE ? 'calculated-fields.expression' : 'calculated-fields.type.script' ) | translate }} + @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 }}
- -
-
- + + +
+
+ @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 }}
+
+ + +
+
+
+ }
{{ 'calculated-fields.output' | translate }}
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 975744b2c6..3037b3a4ce 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 @@ -22,19 +22,22 @@ import { FormBuilder, FormGroup, 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 } from '@shared/models/calculated-field.models'; import { digitsRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; -import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { EntityType } from '@shared/models/entity-type.models'; import { map, startWith, switchMap } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -43,6 +46,8 @@ 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'; export interface CalculatedFieldDialogData { value?: CalculatedField; @@ -63,12 +68,20 @@ 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({ @@ -104,6 +117,10 @@ export class CalculatedFieldDialogComponent extends DialogComponent 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; @@ -113,6 +130,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent, protected router: Router, @@ -125,6 +143,8 @@ export class CalculatedFieldDialogComponent extends DialogComponent 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}); @@ -222,20 +270,36 @@ export class CalculatedFieldDialogComponent extends DialogComponent +
+
+ + + +
{{ 'common.name' | translate }}
+
+ +
+
{{ geofenceZone.name }}
+ +
+
+
+ + + {{ 'entity.entity-type' | translate }} + + +
+ @if (geofenceZone.refEntityId?.entityType === ArgumentEntityType.Tenant) { + {{ 'calculated-fields.argument-current-tenant' | translate }} + } @else if (geofenceZone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery) { + {{ 'calculated-fields.argument-relation-query' | translate }} + } @else if (geofenceZone.refEntityId?.id) { + {{ entityTypeTranslations.get(geofenceZone.refEntityId.entityType).type | translate }} + } @else { + {{ 'calculated-fields.argument-current' | translate }} + } +
+
+
+ + + {{ 'calculated-fields.target-zone' | translate }} + + +
+ @if (geofenceZone.refEntityId?.id && geofenceZone.refEntityId?.entityType !== ArgumentEntityType.Tenant) { + + {{ entityNameMap.get(geofenceZone.refEntityId.id) ?? '' }} + + } +
+
+
+ + + + {{ 'calculated-fields.perimeter-key' | translate }} + + + +
{{ geofenceZone.perimeterKeyName }}
+
+
+
+ + + + {{ 'calculated-fields.report-strategy' | translate }} + + +
{{ GeofencingReportStrategyTranslations.get(geofenceZone.reportStrategy) | translate }}
+
+
+ + + + +
+ + +
+
+
+ + +
+
+ {{ 'calculated-fields.no-zone-configured' | translate }} +
+ @if (errorText) { + + } +
+
+ + @if (maxArgumentsPerCF && zoneGroupsFormArray.length >= maxArgumentsPerCF) { +
+ warning + {{ 'calculated-fields.hint.max-geofencing-zone' | translate }} +
+ } +
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.scss new file mode 100644 index 0000000000..430958d0f4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.scss @@ -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. + */ +:host { + .arguments-table { + min-height: 108px; + + &-with-error { + min-height: 150px; + } + + .mat-mdc-table { + table-layout: fixed; + } + + .key-text { + font-size: 13px; + } + + .copy-argument-name { + visibility: hidden; + transition: visibility 0.1s; + } + + .argument-name-cell:hover { + .copy-argument-name { + visibility: visible; + } + } + } + + .max-args-warning { + .mat-icon { + color: #FAA405; + } + } + + .tb-form-table-row-cell-buttons { + --mat-badge-legacy-small-size-container-size: 8px; + --mat-badge-small-size-container-overlap-offset: -5px; + --mat-badge-small-size-text-size: 0; + } +} + +:host ::ng-deep { + .arguments-table:not(.arguments-table-with-error) { + .mdc-data-table__row:last-child .mat-mdc-cell { + border-bottom: none; + } + } + + .arguments-table { + .mat-mdc-header-row.mat-row-select .mat-mdc-header-cell.entity-type-header { + padding: 0 28px 0 0; + } + } + + .copy-argument-name { + .mat-icon { + font-size: 16px; + padding: 4px; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts new file mode 100644 index 0000000000..a11ae4cea5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts @@ -0,0 +1,307 @@ +/// +/// 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 { + ArgumentEntityType, + CalculatedFieldGeofencing, + CalculatedFieldGeofencingValue, + CalculatedFieldType, + GeofencingReportStrategyTranslations, +} 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 { EntityId } from '@shared/models/id/entity-id'; +import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { getEntityDetailsPageURL, isEqual } from '@core/utils'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract'; +import { EntityService } from '@core/http/entity.service'; +import { MatSort } from '@angular/material/sort'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { forkJoin, Observable } from 'rxjs'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { BaseData } from '@shared/models/base-data'; +import { + CalculatedFieldGeofencingZoneGroupsPanelComponent +} from '@home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component'; + +@Component({ + selector: 'tb-calculated-field-geofencing-zone-groups-table', + templateUrl: './calculated-field-geofencing-zone-groups-table.component.html', + styleUrls: [`calculated-field-geofencing-zone-groups-table.component.scss`], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CalculatedFieldGeofencingZoneGroupsTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CalculatedFieldGeofencingZoneGroupsTableComponent), + multi: true + } + ], +}) +export class CalculatedFieldGeofencingZoneGroupsTableComponent implements ControlValueAccessor, Validator, AfterViewInit { + + @Input() entityId: EntityId; + @Input() tenantId: string; + @Input() entityName: string; + + @ViewChild(MatSort, { static: true }) sort: MatSort; + + errorText = ''; + zoneGroupsFormArray = this.fb.array([]); + entityNameMap = new Map(); + sortOrder = { direction: 'asc', property: '' }; + dataSource = new CalculatedFieldZoneDatasource(); + + readonly GeofencingReportStrategyTranslations = GeofencingReportStrategyTranslations; + readonly entityTypeTranslations = entityTypeTranslations; + readonly ArgumentEntityType = ArgumentEntityType; + readonly maxArgumentsPerCF = getCurrentAuthState(this.store).maxArgumentsPerCF - 2; + readonly NULL_UUID = NULL_UUID; + + 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 entityService: EntityService, + private destroyRef: DestroyRef, + private store: Store + ) { + this.zoneGroupsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => { + this.updateDataSource(value); + this.propagateChange(this.getZonesObject(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.zoneGroupsFormArray.value); + }); + } + + registerOnChange(fn: (zonesObj: Record) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_): void {} + + validate(): ValidationErrors | null { + this.updateErrorText(); + return this.errorText ? { zonesFormArray: false } : null; + } + + onDelete($event: Event, zone: CalculatedFieldGeofencingValue): void { + $event.stopPropagation(); + const index = this.zoneGroupsFormArray.controls.findIndex(control => isEqual(control.value, zone)); + this.zoneGroupsFormArray.removeAt(index); + this.zoneGroupsFormArray.markAsDirty(); + } + + manageZone($event: Event, matButton: MatButton, zone = {} as CalculatedFieldGeofencingValue): 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.zoneGroupsFormArray.controls.findIndex(control => isEqual(control.value, zone)); + const isExists = index !== -1; + const ctx = { + index, + zone, + entityId: this.entityId, + calculatedFieldType: CalculatedFieldType.GEOFENCING, + buttonTitle: isExists ? 'action.apply' : 'action.add', + tenantId: this.tenantId, + entityName: this.entityName, + usedNames: this.zoneGroupsFormArray.value.map(({ name }) => name).filter(name => name !== zone.name), + }; + this.popoverComponent = this.popoverService.displayPopover({ + trigger, + renderer: this.renderer, + componentType: CalculatedFieldGeofencingZoneGroupsPanelComponent, + hostView: this.viewContainerRef, + preferredPlacement: isExists ? ['left', 'leftTop', 'leftBottom'] : ['topRight', 'right', 'rightTop'], + context: ctx, + isModal: true + }); + this.popoverComponent.tbComponentRef.instance.geofencingDataApplied.subscribe(({ entityName, ...value }) => { + this.popoverComponent.hide(); + if (entityName) { + this.entityNameMap.set(value.refEntityId.id, entityName); + } + if (isExists) { + this.zoneGroupsFormArray.at(index).setValue(value); + } else { + this.zoneGroupsFormArray.push(this.fb.control(value)); + } + this.cd.markForCheck(); + }); + } + } + + private updateDataSource(value: CalculatedFieldGeofencingValue[]): void { + const sortedValue = this.sortData(value); + this.dataSource.loadData(sortedValue); + } + + private updateErrorText(): void { + if (this.zoneGroupsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) { + this.errorText = 'calculated-fields.hint.geofencing-entity-not-found'; + } else if (!this.zoneGroupsFormArray.controls.length) { + this.errorText = 'calculated-fields.hint.geofencing-empty'; + } else { + this.errorText = ''; + } + } + + private getZonesObject(value: CalculatedFieldGeofencingValue[]): Record { + return value.reduce((acc, zoneValue) => { + const { name, ...zone } = zoneValue as CalculatedFieldGeofencingValue; + acc[name] = zone; + return acc; + }, {} as Record); + } + + writeValue(zonesObj: Record): void { + this.zoneGroupsFormArray.clear(); + this.populateZonesFormArray(zonesObj); + this.updateEntityNameMap(this.zoneGroupsFormArray.value); + } + + getEntityDetailsPageURL(id: string, type: EntityType): string { + return getEntityDetailsPageURL(id, type); + } + + private populateZonesFormArray(zonesObj: Record): void { + Object.keys(zonesObj).forEach(key => { + const value: CalculatedFieldGeofencingValue = { + ...zonesObj[key], + name: key + }; + this.zoneGroupsFormArray.push(this.fb.control(value), { emitEvent: false }); + }); + this.zoneGroupsFormArray.updateValueAndValidity(); + } + + private updateEntityNameMap(values: CalculatedFieldGeofencingValue[]): void { + const entitiesByType = values.reduce((acc, { refEntityId = {}}) => { + if (refEntityId.id && refEntityId.entityType !== ArgumentEntityType.Tenant) { + const { id, entityType } = refEntityId as EntityId; + acc[entityType] = acc[entityType] ?? []; + acc[entityType].push(id); + } + return acc; + }, {} as Record); + const tasks = Object.entries(entitiesByType).map(([entityType, ids]) => + this.entityService.getEntities(entityType as EntityType, ids) + ); + if (!tasks.length) { + return; + } + this.fetchEntityNames(tasks, values); + } + + private fetchEntityNames(tasks: Observable[]>[], values: CalculatedFieldGeofencingValue[]): void { + forkJoin(tasks as Observable[]>[]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result: Array>[]) => { + result.forEach((entities: BaseData[]) => entities.forEach((entity: BaseData) => this.entityNameMap.set(entity.id.id, entity.name))); + let updateTable = false; + values.forEach(({ refEntityId }) => { + if (refEntityId?.id && !this.entityNameMap.has(refEntityId.id) && refEntityId.entityType !== ArgumentEntityType.Tenant) { + updateTable = true; + const control = this.zoneGroupsFormArray.controls.find(control => control.value.refEntityId?.id === refEntityId.id); + const value = control.value; + value.refEntityId.id = NULL_UUID; + control.setValue(value, { emitEvent: false }); + } + }); + if (updateTable) { + this.zoneGroupsFormArray.updateValueAndValidity(); + } + }); + } + + private getSortValue(zone: CalculatedFieldGeofencingValue, column: string): string { + switch (column) { + case 'entityType': + if (zone.refEntityId?.entityType === ArgumentEntityType.Tenant) { + return 'calculated-fields.argument-current-tenant'; + } else if (zone.refDynamicSourceConfiguration.type === ArgumentEntityType.RelationQuery) { + return 'calculated-fields.argument-relation-query'; + } else if (zone.refEntityId?.id) { + return entityTypeTranslations.get((zone.refEntityId)?.entityType as unknown as EntityType).type; + } else { + return 'calculated-fields.argument-current'; + } + case 'key': + return zone.perimeterKeyName; + case 'reportStrategy': + return GeofencingReportStrategyTranslations.get(zone.reportStrategy); + default: + return zone.name; + } + } + + private sortData(data: CalculatedFieldGeofencingValue[]): CalculatedFieldGeofencingValue[] { + 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 CalculatedFieldZoneDatasource extends TbTableDatasource { + constructor() { + super(); + } +} 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/panel/calculated-field-argument-panel.component.ts index 6f46e47af9..8ccaa4fe49 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/panel/calculated-field-argument-panel.component.ts @@ -86,7 +86,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI entityFilter: EntityFilter; entityNameSubject = new BehaviorSubject(null); - readonly argumentEntityTypes = Object.values(ArgumentEntityType) as ArgumentEntityType[]; + readonly argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[]; readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations; readonly ArgumentType = ArgumentType; readonly DataKeyType = DataKeyType; diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.html new file mode 100644 index 0000000000..a660158ab9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.html @@ -0,0 +1,224 @@ + +
+
+
{{ '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.relation-query' | translate }}* + +
+
{{ 'calculated-fields.direction' | translate }}
+ + + @for (direction of GeofencingDirectionList; track direction) { + {{ GeofencingDirectionTranslations.get(direction) | translate }} + } + + +
+
+
{{ 'calculated-fields.relation-type' | translate }}
+ + +
+
+
{{ 'calculated-fields.relation-level' | translate }}
+ + + @if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('required')) { + + warning + + } @else if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('min')) { + + warning + + } @else if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('max')) { + + warning + + } + +
+
+ + {{ 'calculated-fields.fetch-last-available-level' | translate }} + +
+
+
+
+ +
+
+ {{ 'calculated-fields.perimeter-attribute-key' | translate }} +
+ +
+
+
{{ '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/panel/calculated-field-geofencing-zone-groups-panel.component.scss new file mode 100644 index 0000000000..bedaf2eeb0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.scss @@ -0,0 +1,51 @@ +/** + * 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'; + +$panel-width: 520px; + +:host { + display: flex; + width: $panel-width; + max-width: 100%; + max-height: 80vh; + + .fixed-title-width { + @media #{$mat-xs} { + min-width: 120px; + } + } + + .limit-field-row { + @media screen and (max-width: $panel-width) { + display: flex; + flex-direction: column; + + .fixed-title-width { + align-self: flex-start; + padding-top: 8px; + } + } + } +} + +:host ::ng-deep { + .time-interval-field { + .advanced-input { + flex-direction: column; + } + } +} 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/panel/calculated-field-geofencing-zone-groups-panel.component.ts new file mode 100644 index 0000000000..72ea58919f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.ts @@ -0,0 +1,291 @@ +/// +/// 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, 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'; +import { + ArgumentEntityType, + ArgumentEntityTypeParamsMap, + ArgumentEntityTypeTranslations, + CalculatedFieldGeofencing, + CalculatedFieldGeofencingValue, + CalculatedFieldType, + GeofencingDirectionTranslations, + GeofencingReportStrategy, + GeofencingReportStrategyTranslations, + getCalculatedFieldCurrentEntityFilter +} from '@shared/models/calculated-field.models'; +import { debounceTime, delay, distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { EntityType } from '@shared/models/entity-type.models'; +import { 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'; +import { AliasFilterType } from '@shared/models/alias.models'; +import { BehaviorSubject, merge, Observable, of } from 'rxjs'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +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 { EntitySearchDirection } from '@shared/models/relation.models'; + +@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'] +}) +export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit, AfterViewInit { + + @Input() buttonTitle: string; + @Input() zone: CalculatedFieldGeofencing; + @Input() entityId: EntityId; + @Input() tenantId: string; + @Input() entityName: string; + @Input() calculatedFieldType: CalculatedFieldType; + @Input() usedNames: string[]; + + @ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent; + + geofencingDataApplied = output(); + + readonly maxRelationLevelPerCfArgument = getCurrentAuthState(this.store).maxRelationLevelPerCfArgument; + + geofencingFormGroup = this.fb.group({ + name: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], + refEntityId: this.fb.group({ + entityType: [ArgumentEntityType.Current], + id: [''] + }), + refDynamicSourceConfiguration: this.fb.group({ + direction: [EntitySearchDirection.TO], + relationType: ['', [Validators.required]], + maxLevel: [1, [Validators.required, Validators.min(1), Validators.max(this.maxRelationLevelPerCfArgument)]], + fetchLastLevelOnly: [false], + }), + perimeterKeyName: ['', [Validators.pattern(oneSpaceInsideRegex)]], + reportStrategy: [GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS], + createRelationsWithMatchedZones: [false], + direction: [EntitySearchDirection.TO], + relationType: ['', [Validators.required]] + }); + + entityFilter: EntityFilter; + entityNameSubject = new BehaviorSubject(null); + + readonly ArgumentEntityType = ArgumentEntityType; + readonly argumentEntityTypes = Object.values(ArgumentEntityType) as ArgumentEntityType[]; + readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations; + readonly DataKeyType = DataKeyType; + readonly ArgumentEntityTypeParamsMap = ArgumentEntityTypeParamsMap; + readonly GeofencingReportStrategyList = Object.values(GeofencingReportStrategy) as Array; + readonly GeofencingReportStrategyTranslations = GeofencingReportStrategyTranslations; + readonly GeofencingDirectionList = Object.values(EntitySearchDirection) as Array; + readonly GeofencingDirectionTranslations = GeofencingDirectionTranslations; + + private currentEntityFilter: EntityFilter; + + constructor( + private fb: FormBuilder, + private cd: ChangeDetectorRef, + private popover: TbPopoverComponent, + private store: Store + ) { + + this.observeMaxLevelChanges(); + this.observeEntityFilterChanges(); + this.observeEntityTypeChanges(); + this.observeUpdatePosition(); + this.observeCreateRelationZonesChanges(); + } + + get entityType(): ArgumentEntityType { + return this.geofencingFormGroup.get('refEntityId').get('entityType').value; + } + + get refEntityIdFormGroup(): FormGroup { + return this.geofencingFormGroup.get('refEntityId') as FormGroup; + } + + get refDynamicSourceFormGroup(): FormGroup { + return this.geofencingFormGroup.get('refDynamicSourceConfiguration') as FormGroup; + } + + ngOnInit(): void { + this.geofencingFormGroup.patchValue(this.zone, {emitEvent: false}); + if (this.zone.refDynamicSourceConfiguration?.type) { + this.refEntityIdFormGroup.get('entityType').setValue(this.zone.refDynamicSourceConfiguration.type, {emitEvent: false}); + } + this.validateFetchLastLevelOnly(this.zone?.refDynamicSourceConfiguration?.maxLevel); + this.validateDirectionAndRelationType(this.zone?.createRelationsWithMatchedZones); + this.validateRefDynamicSourceConfiguration(this.zone?.refEntityId?.entityType || this.zone?.refDynamicSourceConfiguration?.type); + + this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId); + this.updateEntityFilter(this.zone.refEntityId?.entityType); + } + + fetchOptions(searchText: string): Observable> { + const search = searchText ? searchText?.toLowerCase() : ''; + 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}); + this.geofencingFormGroup.get('relationType').enable({emitEvent: false}); + } else { + this.geofencingFormGroup.get('direction').disable({emitEvent: false}); + this.geofencingFormGroup.get('relationType').disable({emitEvent: false}); + } + } + + private validateRefDynamicSourceConfiguration(type: ArgumentEntityType = ArgumentEntityType.Current): void { + if (type === ArgumentEntityType.RelationQuery) { + this.refDynamicSourceFormGroup.enable({emitEvent: false}); + } else { + this.refDynamicSourceFormGroup.disable({emitEvent: false}); + } + } + + ngAfterViewInit(): void { + if (this.zone.refEntityId?.id === NULL_UUID) { + this.entityAutocomplete.selectEntityFormGroup.get('entity').markAsTouched(); + } + } + + saveZone(): void { + const value = this.geofencingFormGroup.value as CalculatedFieldGeofencingValue; + const argumentType = value.refEntityId.entityType; + switch (argumentType) { + case ArgumentEntityType.Current: + delete value.refEntityId; + break; + case ArgumentEntityType.RelationQuery: + delete value.refEntityId; + value.refDynamicSourceConfiguration.type = ArgumentEntityType.RelationQuery; + break; + case ArgumentEntityType.Tenant: + value.refEntityId.id = this.tenantId; + break + default: + value.entityName = this.entityNameSubject.value; + } + this.geofencingDataApplied.emit(value); + } + + cancel(): void { + this.popover.hide(); + } + + private updateEntityFilter(entityType: ArgumentEntityType = ArgumentEntityType.Current): void { + let entityFilter: EntityFilter; + switch (entityType) { + case ArgumentEntityType.Current: + case ArgumentEntityType.RelationQuery: + entityFilter = this.currentEntityFilter; + break; + case ArgumentEntityType.Tenant: + entityFilter = { + type: AliasFilterType.singleEntity, + singleEntity: { + id: this.tenantId, + entityType: EntityType.TENANT + }, + }; + break; + default: + entityFilter = { + type: AliasFilterType.singleEntity, + singleEntity: this.geofencingFormGroup.get('refEntityId').value as unknown as EntityId, + }; + } + this.entityFilter = entityFilter; + this.cd.markForCheck(); + } + + private observeEntityFilterChanges(): void { + merge( + this.refEntityIdFormGroup.get('entityType').valueChanges, + this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)), + ) + .pipe(debounceTime(50), takeUntilDestroyed()) + .subscribe(() => this.updateEntityFilter(this.entityType)); + } + + private observeEntityTypeChanges(): void { + this.refEntityIdFormGroup.get('entityType').valueChanges + .pipe(distinctUntilChanged(), takeUntilDestroyed()) + .subscribe(type => { + this.geofencingFormGroup.get('refEntityId').get('id').setValue(''); + const isEntityWithId = type !== ArgumentEntityType.Tenant && type !== ArgumentEntityType.Current && type !== ArgumentEntityType.RelationQuery; + this.geofencingFormGroup.get('refEntityId') + .get('id')[isEntityWithId ? 'enable' : 'disable'](); + if (!isEntityWithId) { + this.entityNameSubject.next(null); + } + this.validateRefDynamicSourceConfiguration(type); + }); + } + + 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; + }; + } + + private observeUpdatePosition(): void { + merge( + this.refEntityIdFormGroup.get('entityType').valueChanges, + this.geofencingFormGroup.get('createRelationsWithMatchedZones').valueChanges + ) + .pipe(delay(50), takeUntilDestroyed()) + .subscribe(() => this.popover.updatePosition()); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 31a3066edf..7298038bd0 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -205,6 +205,12 @@ import { } from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component'; import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialog.component'; +import { + CalculatedFieldGeofencingZoneGroupsTableComponent +} from '@home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component'; +import { + CalculatedFieldGeofencingZoneGroupsPanelComponent +} from '@home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component'; @NgModule({ declarations: @@ -356,6 +362,8 @@ import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialo CalculatedFieldDebugDialogComponent, CalculatedFieldScriptTestDialogComponent, CalculatedFieldTestArgumentsComponent, + CalculatedFieldGeofencingZoneGroupsTableComponent, + CalculatedFieldGeofencingZoneGroupsPanelComponent, CheckConnectivityDialogComponent, AIModelDialogComponent, ], @@ -503,6 +511,8 @@ import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialo CalculatedFieldDebugDialogComponent, CalculatedFieldScriptTestDialogComponent, CalculatedFieldTestArgumentsComponent, + CalculatedFieldGeofencingZoneGroupsTableComponent, + CalculatedFieldGeofencingZoneGroupsPanelComponent, CheckConnectivityDialogComponent, AIModelDialogComponent, ], diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html index 3c5c8774d5..1540a3c87f 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html @@ -275,6 +275,34 @@ + + tenant-profile.max-related-level-per-argument + + + {{ 'tenant-profile.max-related-level-per-argument-required' | translate}} + + + {{ 'tenant-profile.max-related-level-per-argument-range' | translate}} + + + +
+
+ + tenant-profile.min-allowed-scheduled-update-interval + + + {{ 'tenant-profile.min-allowed-scheduled-update-interval-required' | translate}} + + + {{ 'tenant-profile.min-allowed-scheduled-update-interval-range' | translate}} + + +
diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts index c6efd9dee3..0dd1453648 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts @@ -124,6 +124,8 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA edgeUplinkMessagesRateLimitsPerEdge: [null, []], maxCalculatedFieldsPerEntity: [null, [Validators.required, Validators.min(0)]], maxArgumentsPerCF: [null, [Validators.required, Validators.min(0)]], + maxRelationLevelPerCfArgument: [null, [Validators.required, Validators.min(1)]], + minAllowedScheduledUpdateIntervalInSecForCF: [null, [Validators.required, Validators.min(0)]], maxDataPointsPerRollingArg: [null, [Validators.required, Validators.min(0)]], maxStateSizeInKBytes: [null, [Validators.required, Validators.min(0)]], calculatedFieldDebugEventsRateLimit: [null, []], diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.html b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.html index 9afaa889b4..7e75a00067 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.html +++ b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.html @@ -117,9 +117,13 @@ {{ 'relation.to-entity-name' | translate }} - - {{ relation.toName | customTranslate }} - + @if(relation.entityURL){ + + {{ relation.toName | customTranslate }} + + } @else { + {{ relation.toName | customTranslate }} + } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/common-rule-node-config.module.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/common-rule-node-config.module.ts index c7d8ce2723..049e489fde 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/common-rule-node-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/common-rule-node-config.module.ts @@ -33,7 +33,6 @@ import { RelationsQueryConfigOldComponent } from './relations-query-config-old.c import { SelectAttributesComponent } from './select-attributes.component'; import { AlarmStatusSelectComponent } from './alarm-status-select.component'; import { ExampleHintComponent } from './example-hint.component'; -import { TimeUnitInputComponent } from './time-unit-input.component'; @NgModule({ declarations: [ @@ -52,7 +51,6 @@ import { TimeUnitInputComponent } from './time-unit-input.component'; SelectAttributesComponent, AlarmStatusSelectComponent, ExampleHintComponent, - TimeUnitInputComponent ], imports: [ CommonModule, @@ -75,7 +73,6 @@ import { TimeUnitInputComponent } from './time-unit-input.component'; SelectAttributesComponent, AlarmStatusSelectComponent, ExampleHintComponent, - TimeUnitInputComponent ] }) 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..1e1f89bfb8 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/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 }} +
+
+ +
+
+ @for (api of apiUsages; track api){ +
+
+
{{ api.label }}
+
+
{{ api.current.value }} / {{ api.maxLimit.value }}
+
+ +
+
+
{{ ('api-usage.status.' + api.status.value) | translate }}
+
+ @if (api.status.value === 'enabled') { + check_circle + } @else { + warning + } +
+
+
+
+ +
+ } +
+
+
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 new file mode 100644 index 0000000000..fc1247c4f7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.scss @@ -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. + */ +@import "../../../../../../../scss/constants"; + +$enabled-color: #198038; +$disabled-color: #D12730; +$warning-color: #FAA405; + +.tb-no-notification-svg-color { + color: $tb-primary-color; +} + +.tb-api-usage-panel { + > div:not(.tb-api-usage-overlay) { + z-index: 1; + } + .tb-api-usage-overlay { + position: absolute; + top: 12px; + left: 12px; + bottom: 12px; + right: 12px; + } + .tb-api-usage-content { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + .api-items-list { + display: flex; + flex-direction: column; + width: 100%; + .api-item { + &.enabled { + .api-item-statistic-status { + 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}; + } + } + &.disabled { + .api-item-statistic-status { + 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}; + } + } + &.warning { + .api-item-statistic-status { + 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}; + } + } + + &:hover { + background-color: rgba(0, 0, 0, 0.06); + } + &.active { + background-color: rgba($tb-primary-color, 0.06); + .mat-divider { + --mat-divider-color: #{$tb-primary-color}; + } + } + + .api-item-content { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 5px 16px; + .api-item-title { + display: flex; + flex: 1; + font-size: 14px; + font-weight: 500; + color: $tb-primary-color; + } + .api-item-statistic { + display: flex; + flex: 1; + flex-direction: column; + &-count { + font-size: 14px; + color: rgba(0, 0, 0, 0.54); + } + &-status { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + font-size: 14px; + } + &-progress { + --mdc-linear-progress-track-height: 8px; + --mdc-linear-progress-active-indicator-height: 8px; + padding: 4px 0; + .mat-mdc-progress-bar { + border-radius: 6px; + } + } + } + } + } + } + } +} 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 new file mode 100644 index 0000000000..69f328a006 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts @@ -0,0 +1,163 @@ +/// +/// 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, Input, OnDestroy, OnInit, TemplateRef, ViewEncapsulation } from '@angular/core'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models'; +import { Observable } from 'rxjs'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; +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 { + apiUsageDefaultSettings, + ApiUsageWidgetSettings, + getUniqueDataKeys +} from '@home/components/widget/lib/settings/cards/api-usage-settings.component.models'; + +@Component({ + selector: 'tb-api-usage-widget', + templateUrl: './api-usage-widget.component.html', + styleUrls: ['api-usage-widget.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ApiUsageWidgetComponent implements OnInit, OnDestroy { + + settings: ApiUsageWidgetSettings; + + @Input() + ctx: WidgetContext; + + @Input() + widgetTitlePanel: TemplateRef; + + backgroundStyle$: Observable; + overlayStyle: ComponentStyle = {}; + padding: string; + + apiUsages = []; + currentState = ''; + noDataDisplayMessageText: string; + + private contentResize$: ResizeObserver; + private powers: {key: string, value: number}[] = [ + { key: 'Q', value: 1e15 }, + { key: 'T', value: 1e12 }, + { key: 'B', value: 1e9 }, + { key: 'M', value: 1e6 }, + { key: 'K', value: 1e3 } + ]; + + constructor(private imagePipe: ImagePipe, + private utils: UtilsService, + private sanitizer: DomSanitizer, + private cd: ChangeDetectorRef) { + } + + ngOnInit(): void { + this.ctx.$scope.apiUsageWidget = this; + this.settings = {...apiUsageDefaultSettings, ...this.ctx.settings}; + + this.parseApiUsages(); + + const ds = { + type: DatasourceType.entity, + name: '', + entityAliasId: this.settings.dsEntityAliasId, + dataKeys: getUniqueDataKeys(this.settings.apiUsageDataKeys) + } + + const apiUsageSubscriptionOptions: WidgetSubscriptionOptions = { + datasources: [ds], + useDashboardTimewindow: false, + type: widgetType.latest, + callbacks: { + onDataUpdated: (subscription) => { + const data = formattedDataFormDatasourceData(subscription.data); + this.apiUsages.forEach(key => { + 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.current.value = isFinite(data[0][key.current.key]) ? this.toShortNumber(data[0][key.current.key]) : 0; + }); + this.cd.detectChanges(); + } + } + }; + this.ctx.subscriptionApi.createSubscription(apiUsageSubscriptionOptions, true).subscribe(); + + this.currentState = this.ctx.stateController.getStateId(); + this.ctx.stateController.stateId().subscribe((state) => { + this.ctx.updateParamsFromData(true); + this.currentState = state; + this.cd.markForCheck(); + }); + this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); + this.overlayStyle = overlayStyle(this.settings.background.overlay); + this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding; + } + + updateState($event: MouseEvent, stateName: string) { + $event?.preventDefault(); + if (stateName?.length) { + this.ctx.stateController.updateState(stateName, this.ctx.stateController.getStateParams(), this.ctx.isMobile); + } + } + + parseApiUsages() { + this.settings.apiUsageDataKeys.forEach((key) => { + this.apiUsages.push({ + label: this.utils.customTranslation(key.label, key.label), + state: key.state, + progress: 0, + status: {key: key.status.name, value: 'enabled'}, + maxLimit: {key: key.maxLimit.name, value: '∞'}, + current: {key: key.current.name, value: 0}, + }); + }) + } + + ngOnDestroy() { + if (this.contentResize$) { + this.contentResize$.disconnect(); + } + } + + private toShortNumber(number: any, decimals = 1) { + if (!Number.isFinite(number) || number < 0) { + return '0'; + } + for (const power of this.powers) { + if (number >= power.value) { + const reduced = number / power.value; + const rounded = Number(reduced.toFixed(decimals)); + return `${rounded}${power.key}`; + } + } + return `${Number(number.toFixed(decimals))}`; + } + + public onInit() { + const borderRadius = this.ctx.$widgetElement.css('borderRadius'); + this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; + this.cd.detectChanges(); + } + +} 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/settings/cards/api-usage-data-key-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-data-key-row.component.html new file mode 100644 index 0000000000..3ada7fc5a0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-data-key-row.component.html @@ -0,0 +1,85 @@ + +
+
+ + + + + +
+
+ + + + + + +
+
+
+ +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-data-key-row.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-data-key-row.component.scss new file mode 100644 index 0000000000..0d690d5877 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-data-key-row.component.scss @@ -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. + */ +@import '../../../../../../../../scss/constants'; + +.tb-form-table-row.tb-api-usage-data-key-row { + + .tb-source-field { + flex: 1 1 25%; + display: flex; + gap: 12px; + .tb-label-field { + flex: 1; + } + } + + .tb-data-key-fields { + display: flex; + gap: 12px; + min-width: 0; + flex: 1 1 50%; + } + + .tb-data-key-field { + flex: 1; + min-width: 0; + } + + .tb-remove-button { + width: 40px; + min-width: 40px; + } + + @media #{$mat-lt-lg} { + .tb-source-field { + flex-direction: column; + flex: 1 1 50%; + } + .tb-data-key-fields{ + flex-direction: column; + flex: 1 1 50%; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-data-key-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-data-key-row.component.ts new file mode 100644 index 0000000000..df3e02f04b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-data-key-row.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 { + ChangeDetectorRef, + Component, + DestroyRef, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + ViewEncapsulation +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { DataKey, DatasourceType, widgetType } from '@shared/models/widget.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { + ApiUsageDataKeysSettings, + ApiUsageSettingsContext +} from "@home/components/widget/lib/settings/cards/api-usage-settings.component.models"; +import { Observable, of } from "rxjs"; + +@Component({ + selector: 'tb-api-usage-data-key-row', + templateUrl: './api-usage-data-key-row.component.html', + styleUrls: ['./api-usage-data-key-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ApiUsageDataKeyRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class ApiUsageDataKeyRowComponent implements ControlValueAccessor, OnInit { + + DatasourceType = DatasourceType; + DataKeyType = DataKeyType; + + widgetType = widgetType; + + @Input() + disabled: boolean; + + @Input() + dsEntityAliasId: string; + + @Input() + context: ApiUsageSettingsContext; + + @Output() + dataKeyRemoved = new EventEmitter(); + + dataKeyFormGroup: UntypedFormGroup; + + modelValue: ApiUsageDataKeysSettings; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private cd: ChangeDetectorRef, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + this.dataKeyFormGroup = this.fb.group({ + label: [null, [Validators.required]], + state: [null, []], + status: [null, [Validators.required]], + maxLimit: [null, [Validators.required]], + current: [null, [Validators.required]] + }); + this.dataKeyFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => this.updateModel() + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.dataKeyFormGroup.disable({emitEvent: false}); + } else { + this.dataKeyFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: ApiUsageDataKeysSettings): void { + this.modelValue = value; + this.dataKeyFormGroup.patchValue( + { + label: value?.label, + state: value?.state, + status: value?.status, + maxLimit: value?.maxLimit, + current: value?.current + }, {emitEvent: false} + ); + this.updateValidators(); + this.cd.markForCheck(); + } + + editKey(keyType: 'status' | 'maxLimit' | 'current') { + const targetDataKey: DataKey = this.dataKeyFormGroup.get(keyType).value; + this.context.editKey(targetDataKey, this.dsEntityAliasId).subscribe( + (updatedDataKey) => { + if (updatedDataKey) { + this.dataKeyFormGroup.get(keyType).patchValue(updatedDataKey); + } + } + ); + } + + private updateValidators() { + } + + private updateModel() { + this.modelValue = {...this.modelValue, ...this.dataKeyFormGroup.value}; + this.propagateChange(this.modelValue); + } + + fetchDashboardStates(searchText?: string): Observable> { + return of(this.context.callbacks.fetchDashboardStates(searchText)); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-settings.component.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-settings.component.models.ts new file mode 100644 index 0000000000..7f4312cef9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-settings.component.models.ts @@ -0,0 +1,118 @@ +/// +/// 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 { IAliasController } from '@core/api/widget-api.models'; +import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; +import { DataKey, Widget, widgetType } from '@shared/models/widget.models'; +import { Observable } from 'rxjs'; +import { BackgroundSettings, BackgroundType } from '@shared/models/widget-settings.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { materialColors } from '@shared/models/material.models'; + +export interface ApiUsageSettingsContext { + aliasController: IAliasController; + callbacks: WidgetConfigCallbacks; + widget: Widget; + editKey: (key: DataKey, entityAliasId: string, WidgetType?: widgetType) => Observable; + generateDataKey: (key: DataKey) => DataKey; +} + + +export interface ApiUsageWidgetSettings { + dsEntityAliasId: string; + apiUsageDataKeys: ApiUsageDataKeysSettings[]; + targetDashboardState: string; + background: BackgroundSettings; + padding: string; +} + +export interface ApiUsageDataKeysSettings { + label: string; + state: string; + status: DataKey; + maxLimit: DataKey; + current: DataKey; +} + +const generateDataKey = (label: string, status: string, maxLimit: string, current: string) => { + return { + label, + state: '', + status: { + name: status, + label: status, + type: DataKeyType.timeseries, + funcBody: undefined, + settings: {}, + color: materialColors[0].value + }, + maxLimit: { + name: maxLimit, + label: maxLimit, + type: DataKeyType.timeseries, + funcBody: undefined, + settings: {}, + color: materialColors[0].value + }, + current: { + name: current, + label: current, + type: DataKeyType.timeseries, + funcBody: undefined, + settings: {}, + color: materialColors[0].value + } + } +} + +export const apiUsageDefaultSettings: ApiUsageWidgetSettings = { + dsEntityAliasId: '', + apiUsageDataKeys: [ + generateDataKey('{i18n:api-usage.transport-messages}', 'transportApiState', 'transportMsgLimit', 'transportMsgCount'), + generateDataKey('{i18n:api-usage.transport-data-points}', 'transportApiState', 'transportDataPointsLimit', 'transportDataPointsCount'), + generateDataKey('{i18n:api-usage.rule-engine-executions}', 'ruleEngineApiState', 'ruleEngineExecutionLimit', 'ruleEngineExecutionCount'), + generateDataKey('{i18n:api-usage.javascript-function-executions}', 'jsExecutionApiState', 'jsExecutionLimit', 'jsExecutionCount'), + generateDataKey('{i18n:api-usage.tbel-function-executions}', 'tbelExecutionApiState', 'tbelExecutionLimit', 'tbelExecutionCount'), + generateDataKey('{i18n:api-usage.data-points-storage-days}', 'dbApiState', 'storageDataPointsLimit', 'storageDataPointsCount'), + generateDataKey('{i18n:api-usage.alarms-created}', 'alarmApiState', 'createdAlarmsLimit', 'createdAlarmsCount'), + generateDataKey('{i18n:api-usage.emails}', 'emailApiState', 'emailLimit', 'emailCount'), + generateDataKey('{i18n:api-usage.sms}', 'notificationApiState', 'smsLimit', 'smsCount'), + ], + targetDashboardState: 'default', + background: { + type: BackgroundType.color, + color: '#fff', + overlay: { + enabled: false, + color: 'rgba(255,255,255,0.72)', + blur: 3 + } + }, + padding: '0' +}; + +export const 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; + }); +}; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.html new file mode 100644 index 0000000000..e96a654c44 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.html @@ -0,0 +1,99 @@ + + +
+
widget-config.datasource
+ + + +
+
+
+
widgets.api-usage.label
+
widgets.api-usage.state-name
+
widgets.api-usage.status
+
widgets.api-usage.limit
+
widgets.api-usage.current-number
+
+
+
+
+
+ + +
+ +
+
+
+
+
+
+ +
+
+ + {{ 'widgets.api-usage.no-key' | translate }} + + + +
+ +
+
widget-config.card-appearance
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.scss new file mode 100644 index 0000000000..9543abb44b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.scss @@ -0,0 +1,62 @@ +/** + * 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'; + +.tb-map-data-layers { + .tb-form-table-header-cell { + &.tb-source-header { + flex: 1 1 50%; + } + &.tb-x-pos-header { + flex: 1 1 25%; + } + &.tb-y-pos-header { + flex: 1 1 25%; + } + &.tb-key-header { + flex: 1 1 50%; + } + &.tb-actions-header { + width: 80px; + min-width: 80px; + } + @media #{$mat-lt-lg} { + &.tb-source-header { + flex: 1 1 30%; + } + &.tb-x-pos-header, &.tb-y-pos-header { + flex: 1 1 35%; + } + &.tb-key-header { + flex: 1 1 70%; + } + } + @media #{$mat-xs} { + &.tb-x-pos-header, &.tb-y-pos-header { + display: none; + } + &.tb-key-header { + display: none; + } + } + } + + .tb-form-table-body { + tb-api-usage-data-key-row { + overflow: hidden; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.ts new file mode 100644 index 0000000000..1d42b1503c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.ts @@ -0,0 +1,211 @@ +/// +/// 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 } from '@angular/core'; +import { + DataKey, + DataKeyConfigMode, + WidgetSettings, + WidgetSettingsComponent, + widgetType +} from '@shared/models/widget.models'; +import { + AbstractControl, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormGroup, + ValidationErrors, + ValidatorFn +} from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + ApiUsageDataKeysSettings, + apiUsageDefaultSettings, + ApiUsageSettingsContext +} from '@home/components/widget/lib/settings/cards/api-usage-settings.component.models'; +import { deepClone } from '@core/utils'; +import { Observable, of } from 'rxjs'; +import { + DataKeyConfigDialogComponent, + DataKeyConfigDialogData +} from '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component'; +import { MatDialog } from '@angular/material/dialog'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; + +@Component({ + selector: 'tb-api-usage-widget-settings', + templateUrl: './api-usage-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss', 'api-usage-widget-settings.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ApiUsageWidgetSettingsComponent), + multi: true + } + ], +}) +export class ApiUsageWidgetSettingsComponent extends WidgetSettingsComponent { + + apiUsageWidgetSettingsForm: UntypedFormGroup; + + context: ApiUsageSettingsContext; + + constructor(protected store: Store, + private dialog: MatDialog, + private fb: UntypedFormBuilder) { + super(store); + } + + ngOnInit() { + this.context = { + aliasController: this.aliasController, + callbacks: this.callbacks, + widget: this.widget, + editKey: this.editKey.bind(this), + generateDataKey: this.generateDataKey.bind(this) + }; + } + + protected doUpdateSettings(settingsForm: UntypedFormGroup, settings: WidgetSettings) { + settingsForm.setControl('apiUsageDataKeys', this.prepareDataKeysFormArray(settings?.apiUsageDataKeys), {emitEvent: false}); + } + + dataKeysFormArray(): UntypedFormArray { + return this.apiUsageWidgetSettingsForm.get('apiUsageDataKeys') as UntypedFormArray; + } + + trackByDataKey(index: number): any { + return index; + } + + get dragEnabled(): boolean { + return this.dataKeysFormArray().controls.length > 1; + } + + layerDrop(event: CdkDragDrop) { + const layer = this.dataKeysFormArray().at(event.previousIndex); + this.dataKeysFormArray().removeAt(event.previousIndex); + this.dataKeysFormArray().insert(event.currentIndex, layer); + } + + removeDataKey(index: number) { + (this.apiUsageWidgetSettingsForm.get('apiUsageDataKeys') as UntypedFormArray).removeAt(index); + } + + addDataKey() { + const dataKey = { + label: '', + state: '', + status: null, + maxLimit: null, + current: null + }; + const dataKeysArray = this.apiUsageWidgetSettingsForm.get('apiUsageDataKeys') as UntypedFormArray; + const dataKeyControl = this.fb.control(dataKey, [this.apiUsageDataKeyValidator()]); + dataKeysArray.push(dataKeyControl); + } + + protected settingsForm(): UntypedFormGroup { + return this.apiUsageWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return apiUsageDefaultSettings; + } + + protected prepareInputSettings(settings: WidgetSettings): WidgetSettings { + return { + dsEntityAliasId: settings?.dsEntityAliasId, + apiUsageDataKeys: settings?.apiUsageDataKeys, + targetDashboardState: settings?.targetDashboardState, + background: settings?.background, + padding: settings.padding + }; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.apiUsageWidgetSettingsForm = this.fb.group({ + dsEntityAliasId: [settings?.dsEntityAliasId], + apiUsageDataKeys: this.prepareDataKeysFormArray(settings?.apiUsageDataKeys), + targetDashboardState: [settings?.targetDashboardState], + background: [settings?.background, []], + padding: [settings.padding, []] + }); + } + + private prepareDataKeysFormArray(dataKeys: ApiUsageDataKeysSettings[]): UntypedFormArray { + const dataKeysControls: Array = []; + if (dataKeys) { + dataKeys.forEach((dataLayer) => { + dataKeysControls.push(this.fb.control(dataLayer, [this.apiUsageDataKeyValidator()])); + }); + } + return this.fb.array(dataKeysControls); + } + + protected validatorTriggers(): string[] { + return []; + } + + protected updateValidators() { + } + + apiUsageDataKeyValidator = (): ValidatorFn => { + return (control: AbstractControl): ValidationErrors | null => { + const value: ApiUsageDataKeysSettings = control.value; + if (!value?.label || !value?.current || !value?.maxLimit || !value?.status) { + return { + dataKey: true + } + } + return null; + }; + }; + + private editKey(key: DataKey, entityAliasId: string, _widgetType = widgetType.latest): Observable { + return this.dialog.open(DataKeyConfigDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + dataKey: deepClone(key), + dataKeyConfigMode: DataKeyConfigMode.general, + aliasController: this.aliasController, + widgetType: _widgetType, + entityAliasId, + showPostProcessing: true, + callbacks: this.callbacks, + hideDataKeyColor: true, + hideDataKeyDecimals: true, + hideDataKeyUnits: true, + widget: this.widget, + dashboard: null, + dataKeySettingsForm: null, + dataKeySettingsDirective: null + } + }).afterClosed(); + } + + private generateDataKey(key: DataKey): DataKey { + return this.callbacks.generateDataKey(key.name, key.type, null, false, null); + } + + fetchDashboardStates(searchText?: string): Observable> { + return of(this.callbacks.fetchDashboardStates(searchText)); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts index fbf9b2eec2..9e4de41841 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts @@ -375,6 +375,12 @@ import { ValueStepperWidgetSettingsComponent } from '@home/components/widget/lib/settings/control/value-stepper-widget-settings.component'; import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings/map/map-widget-settings.component'; +import { + ApiUsageWidgetSettingsComponent +} from "@home/components/widget/lib/settings/cards/api-usage-widget-settings.component"; +import { + ApiUsageDataKeyRowComponent +} from "@home/components/widget/lib/settings/cards/api-usage-data-key-row.component"; @NgModule({ declarations: [ @@ -508,7 +514,9 @@ import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings LabelValueCardWidgetSettingsComponent, UnreadNotificationWidgetSettingsComponent, ScadaSymbolWidgetSettingsComponent, - MapWidgetSettingsComponent + MapWidgetSettingsComponent, + ApiUsageWidgetSettingsComponent, + ApiUsageDataKeyRowComponent ], imports: [ CommonModule, @@ -647,7 +655,8 @@ import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings LabelValueCardWidgetSettingsComponent, UnreadNotificationWidgetSettingsComponent, ScadaSymbolWidgetSettingsComponent, - MapWidgetSettingsComponent + MapWidgetSettingsComponent, + ApiUsageWidgetSettingsComponent ] }) export class WidgetSettingsModule { 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..73ea953a15 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 @@ -47,7 +47,7 @@ - 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..47582d9855 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 @@ -513,7 +513,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-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index cdc829a96f..fab51613d4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -94,6 +94,7 @@ import { SelectMapEntityPanelComponent } from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component'; import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component'; +import { ApiUsageWidgetComponent } from "@home/components/widget/lib/cards/api-usage-widget.component"; @NgModule({ declarations: [ @@ -151,7 +152,8 @@ import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/pane ScadaSymbolWidgetComponent, SelectMapEntityPanelComponent, MapTimelinePanelComponent, - MapWidgetComponent + MapWidgetComponent, + ApiUsageWidgetComponent ], imports: [ CommonModule, @@ -214,7 +216,8 @@ import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/pane UnreadNotificationWidgetComponent, NotificationTypeFilterPanelComponent, ScadaSymbolWidgetComponent, - MapWidgetComponent + MapWidgetComponent, + ApiUsageWidgetComponent ], providers: [ {provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule}, diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index 8d46ede134..35c6fde3f1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -71,6 +71,7 @@ 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/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index 4a22129ba8..c582a21b3f 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -147,6 +147,7 @@ export interface WidgetAction extends IWidgetAction { export interface IDashboardWidget { updateWidgetParams(): void; + updateParamsFromData(detectChanges?: boolean): void; } export class WidgetContext { @@ -478,6 +479,10 @@ export class WidgetContext { } } + updateParamsFromData(detectChanges = false) { + this.dashboardWidget.updateParamsFromData(detectChanges); + } + updateAliases(aliasIds?: Array) { this.aliasController.updateAliases(aliasIds); } diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts index cd992f8886..341bbd2e06 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts @@ -81,7 +81,9 @@ export class JsLibraryTableConfigResolver { search: 'javascript.search', selectedEntities: 'javascript.selected-javascript-resources' }; - this.config.entityResources = entityTypeResources.get(EntityType.TB_RESOURCE); + this.config.entityResources = { + helpLinkId: 'jsExtension' + }; this.config.headerComponent = JsLibraryTableHeaderComponent; this.config.entityTitle = (resource) => resource ? diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html index 379e37528f..755fb45db1 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-form.component.html @@ -28,6 +28,12 @@ [class.!hidden]="isEdit || dashboardScope !== 'tenant'"> {{'dashboard.export' | 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/notification/sent/sent-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html index cb157a0904..424d952c3e 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html @@ -233,7 +233,7 @@
{{ preview.processedTemplates.MICROSOFT_TEAMS.subject }}
{{ preview.processedTemplates.MICROSOFT_TEAMS.body }} - +
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 839a2e00cd..efc90ec048 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 @@ -16,7 +16,7 @@ --> - warning diff --git a/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts index 84d723d114..a86fc70115 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts @@ -14,7 +14,17 @@ /// limitations under the License. /// -import { Component, effect, ElementRef, forwardRef, input, OnChanges, SimpleChanges, ViewChild, } from '@angular/core'; +import { + Component, + effect, + ElementRef, + forwardRef, + Input, + input, + OnChanges, + SimpleChanges, + ViewChild, +} from '@angular/core'; import { ControlValueAccessor, FormBuilder, @@ -32,6 +42,7 @@ import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry. import { EntitiesKeysByQuery } from '@shared/models/entity.models'; import { EntityFilter } from '@shared/models/query/query.models'; import { isEqual } from '@core/utils'; +import { TranslateService } from '@ngx-translate/core'; @Component({ selector: 'tb-entity-key-autocomplete', @@ -53,6 +64,9 @@ export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Val @ViewChild('keyInput', {static: true}) keyInput: ElementRef; + @Input() placeholder = this.translate.instant('action.set'); + @Input() requiredText = this.translate.instant('common.hint.key-required'); + entityFilter = input.required(); dataKeyType = input.required(); keyScopeType = input(); @@ -96,6 +110,7 @@ export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Val constructor( private fb: FormBuilder, private entityService: EntityService, + private translate: TranslateService, ) { this.keyControl.valueChanges .pipe(takeUntilDestroyed()) diff --git a/ui-ngx/src/app/shared/components/icon.component.ts b/ui-ngx/src/app/shared/components/icon.component.ts index 2b6170b71c..20ee9c4a72 100644 --- a/ui-ngx/src/app/shared/components/icon.component.ts +++ b/ui-ngx/src/app/shared/components/icon.component.ts @@ -33,6 +33,9 @@ import { Subscription } from 'rxjs'; import { take } from 'rxjs/operators'; import { isSvgIcon, splitIconName } from '@shared/models/icon.models'; import { ContentObserver } from '@angular/cdk/observers'; +import { isTbImage } from '@shared/models/resource.models'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; const _TbIconBase = mixinColor( class { @@ -70,7 +73,7 @@ const funcIriPattern = /^url\(['"]?#(.*?)['"]?\)$/; host: { role: 'img', class: 'mat-icon notranslate', - '[attr.data-mat-icon-type]': '!_useSvgIcon ? "font" : "svg"', + '[attr.data-mat-icon-type]': '_useSvgIcon ? "svg" : (_useImageIcon ? null : "font")', '[attr.data-mat-icon-name]': '_svgName', '[attr.data-mat-icon-namespace]': '_svgNamespace', '[class.mat-icon-no-color]': 'color !== "primary" && color !== "accent" && color !== "warn"', @@ -99,6 +102,9 @@ export class TbIconComponent extends _TbIconBase private _textElement = null; + _useImageIcon = false; + private _imageElement = null; + private _previousPath?: string; private _elementsWithExternalReferences?: Map; @@ -109,6 +115,8 @@ export class TbIconComponent extends _TbIconBase private contentObserver: ContentObserver, private renderer: Renderer2, private _iconRegistry: MatIconRegistry, + private imagePipe: ImagePipe, + private sanitizer: DomSanitizer, @Inject(MAT_ICON_LOCATION) private _location: MatIconLocation, private readonly _errorHandler: ErrorHandler) { super(elementRef); @@ -148,16 +156,29 @@ export class TbIconComponent extends _TbIconBase private _updateIcon() { const useSvgIcon = isSvgIcon(this.icon); + const useImageIcon = isTbImage(this.icon); if (this._useSvgIcon !== useSvgIcon) { this._useSvgIcon = useSvgIcon; if (!this._useSvgIcon) { this._updateSvgIcon(undefined); } else { this._updateFontIcon(undefined); + this._updateImageIcon(undefined); + } + } + if (this._useImageIcon !== useImageIcon) { + this._useImageIcon = useImageIcon; + if (!this._useImageIcon) { + this._updateImageIcon(undefined); + } else { + this._updateFontIcon(undefined); + this._updateSvgIcon(undefined); } } if (this._useSvgIcon) { this._updateSvgIcon(this.icon); + } else if (this._useImageIcon) { + this._updateImageIcon(this.icon); } else { this._updateFontIcon(this.icon); } @@ -278,4 +299,49 @@ export class TbIconComponent extends _TbIconBase } } + private _updateImageIcon(rawName: string | undefined) { + if (rawName) { + this._clearImageIcon(); + this.imagePipe.transform(rawName, { asString: true, ignoreLoadingImage: true }).subscribe( + imageUrl => { + const urlStr = imageUrl as string; + const isSvg = urlStr?.startsWith('data:image/svg+xml') || urlStr?.endsWith('.svg'); + if (isSvg) { + const safeUrl = this.sanitizer.bypassSecurityTrustResourceUrl(urlStr); + this._iconRegistry + .getSvgIconFromUrl(safeUrl) + .pipe(take(1)) + .subscribe({ + next: (svg) => { + this.renderer.insertBefore(this._elementRef.nativeElement, svg, this._iconNameContent.nativeElement); + this._imageElement = svg; + }, + error: () => this._setImageElement(urlStr) + }); + } else { + this._setImageElement(urlStr); + } + } + ); + } else { + this._clearImageIcon(); + } + } + + private _setImageElement(urlStr: string) { + const imgElement = this.renderer.createElement('img'); + this.renderer.addClass(imgElement, 'mat-icon'); + this.renderer.setAttribute(imgElement, 'alt', 'Image icon'); + this.renderer.setAttribute(imgElement, 'src', urlStr); + this.renderer.insertBefore(this._elementRef.nativeElement, imgElement, this._iconNameContent.nativeElement); + this._imageElement = imgElement; + } + + private _clearImageIcon() { + const elem: HTMLElement = this._elementRef.nativeElement; + if (this._imageElement !== null) { + this.renderer.removeChild(elem, this._imageElement); + this._imageElement = null; + } + } } diff --git a/ui-ngx/src/app/shared/components/material-icon-select.component.ts b/ui-ngx/src/app/shared/components/material-icon-select.component.ts index 9432b3c96e..2bbc5b7528 100644 --- a/ui-ngx/src/app/shared/components/material-icon-select.component.ts +++ b/ui-ngx/src/app/shared/components/material-icon-select.component.ts @@ -71,6 +71,10 @@ export class MaterialIconSelectComponent extends PageComponent implements OnInit @coerceBoolean() iconClearButton = false; + @Input() + @coerceBoolean() + allowedCustomIcon = false; + private requiredValue: boolean; get required(): boolean { return this.requiredValue; @@ -169,7 +173,8 @@ export class MaterialIconSelectComponent extends PageComponent implements OnInit this.viewContainerRef, MaterialIconsComponent, 'left', true, null, { selectedIcon: this.materialIconFormGroup.get('icon').value, - iconClearButton: this.iconClearButton + iconClearButton: this.iconClearButton, + allowedCustomIcon: this.allowedCustomIcon, }, {}, {}, {}, true); diff --git a/ui-ngx/src/app/shared/components/material-icons.component.html b/ui-ngx/src/app/shared/components/material-icons.component.html index d4d9fe93a6..b6880e4ffe 100644 --- a/ui-ngx/src/app/shared/components/material-icons.component.html +++ b/ui-ngx/src/app/shared/components/material-icons.component.html @@ -16,63 +16,86 @@ -->
-
icon.icons
- - search - - - - -
- - - - +
+ icon.icons + @if (allowedCustomIcon) { + + {{ 'resource.system' | translate }} + {{ 'icon.custom' | translate }} + + + } +
+ @if (!isCustomIcon) { + + search + + + + +
+ + + + +
+
+ +
+
+
{{ 'icon.no-icons-found' | translate:{iconSearch: searchIconControl.value} }}
+
+
+
+ + +
- - -
-
-
{{ 'icon.no-icons-found' | translate:{iconSearch: searchIconControl.value} }}
+ } @else { + + +
+ +
- -
- - - -
+ }
diff --git a/ui-ngx/src/app/shared/components/material-icons.component.ts b/ui-ngx/src/app/shared/components/material-icons.component.ts index ece1a99108..d144fd45e6 100644 --- a/ui-ngx/src/app/shared/components/material-icons.component.ts +++ b/ui-ngx/src/app/shared/components/material-icons.component.ts @@ -37,6 +37,7 @@ import { TbPopoverComponent } from '@shared/components/popover.component'; import { BreakpointObserver } from '@angular/cdk/layout'; import { MediaBreakpoints } from '@shared/models/constants'; import { coerceBoolean } from '@shared/decorators/coercion'; +import { isTbImage } from '@shared/models/resource.models'; @Component({ selector: 'tb-material-icons', @@ -61,6 +62,10 @@ export class MaterialIconsComponent extends PageComponent implements OnInit { @coerceBoolean() showTitle = true; + @Input() + @coerceBoolean() + allowedCustomIcon = false; + @Input() popover: TbPopoverComponent; @@ -71,6 +76,8 @@ export class MaterialIconsComponent extends PageComponent implements OnInit { showAllSubject = new BehaviorSubject(false); searchIconControl: UntypedFormControl; + isCustomIcon = false; + iconsRowHeight = 48; iconsPanelHeight: string; @@ -122,14 +129,15 @@ export class MaterialIconsComponent extends PageComponent implements OnInit { map((data) => data.iconRows), share() ); + this.isCustomIcon = isTbImage(this.selectedIcon) } clearSearch() { this.searchIconControl.patchValue('', {emitEvent: true}); } - selectIcon(icon: MaterialIcon) { - this.iconSelected.emit(icon.name); + selectIcon(icon: string) { + this.iconSelected.emit(icon); } clearIcon() { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html b/ui-ngx/src/app/shared/components/time-unit-input.component.html similarity index 82% rename from ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html rename to ui-ngx/src/app/shared/components/time-unit-input.component.html index 0da1cf4023..d5f6319576 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html +++ b/ui-ngx/src/app/shared/components/time-unit-input.component.html @@ -28,19 +28,18 @@
@if (inlineField) { - warning - - } @else { - - - {{ hasError }} - + matTooltipPosition="above" + matTooltipClass="tb-error-tooltip" + [matTooltip]="hasError" + *ngIf="hasError" + class="tb-error"> + warning + } + + + {{ hasError }} + + Validators.min(Math.ceil(this.minTime / this.timeIntervalsInSec.get(this.timeInputForm.get('timeUnit').value)))(control) + ); } timeControl.setValidators(validators); 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/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 }} , 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity { configuration: CalculatedFieldConfiguration; @@ -43,19 +40,23 @@ export interface CalculatedField extends Omit, 'labe export enum CalculatedFieldType { SIMPLE = 'SIMPLE', SCRIPT = 'SCRIPT', + GEOFENCING = 'GEOFENCING' } export const CalculatedFieldTypeTranslations = new Map( [ [CalculatedFieldType.SIMPLE, 'calculated-fields.type.simple'], [CalculatedFieldType.SCRIPT, 'calculated-fields.type.script'], + [CalculatedFieldType.GEOFENCING, 'calculated-fields.type.geofencing'], ] ) export interface CalculatedFieldConfiguration { type: CalculatedFieldType; - expression: string; - arguments: Record; + expression?: string; + arguments?: Record; + zoneGroups?: Record; + scheduledUpdateInterval?: number; output: CalculatedFieldOutput; } @@ -72,6 +73,7 @@ export enum ArgumentEntityType { Asset = 'ASSET', Customer = 'CUSTOMER', Tenant = 'TENANT', + RelationQuery = 'RELATION_QUERY', } export const ArgumentEntityTypeTranslations = new Map( @@ -81,6 +83,28 @@ export const ArgumentEntityTypeTranslations = new Map( + [ + [GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, 'calculated-fields.report-transition-event-and-presence'], + [GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_ONLY, 'calculated-fields.report-transition-event-only'], + [GeofencingReportStrategy.REPORT_PRESENCE_STATUS_ONLY, 'calculated-fields.report-presence-status-only'] + ] +) + +export const GeofencingDirectionTranslations = new Map( + [ + [EntitySearchDirection.FROM, 'calculated-fields.direction-from'], + [EntitySearchDirection.TO, 'calculated-fields.direction-to'], ] ) @@ -131,6 +155,29 @@ export interface CalculatedFieldArgument { timeWindow?: number; } +export interface CalculatedFieldGeofencing { + perimeterKeyName: string; + reportStrategy: GeofencingReportStrategy; + refEntityId?: RefEntityId; + refDynamicSourceConfiguration: RefDynamicSourceConfiguration; + createRelationsWithMatchedZones: boolean; + relationType: string; + direction: EntitySearchDirection; +} + +export interface RefDynamicSourceConfiguration { + type?: ArgumentEntityType.RelationQuery; + direction: EntitySearchDirection; + relationType: string; + maxLevel: number; + fetchLastLevelOnly?: boolean; +} + +export interface CalculatedFieldGeofencingValue extends CalculatedFieldGeofencing { + name: string; + entityName?: string; +} + export interface RefEntityKey { key: string; type: ArgumentType; diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index 76a501cc63..f88c6a1216 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -177,6 +177,7 @@ export const HelpLinks = { entitiesImport: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/bulk-provisioning`, rulechains: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/rule-chains`, lwm2mResourceLibrary: `${helpBaseUrl}/docs${docPlatformPrefix}/reference/lwm2m-api`, + jsExtension: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/contribution/ui/advanced-development`, dashboards: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/dashboards`, otaUpdates: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ota-updates`, widgetTypes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/widget-library/#widget-types`, diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index 0419e40a7c..c1e692e04f 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -188,6 +188,7 @@ export const isImageResourceUrl = (url: string): boolean => url && IMAGES_URL_RE export const isJSResourceUrl = (url: string): boolean => url && RESOURCES_URL_REGEXP.test(url); export const isJSResource = (url: string): boolean => url?.startsWith(TB_RESOURCE_PREFIX); +export const isTbImage = (url: string): boolean => url?.startsWith(TB_IMAGE_PREFIX); export const extractParamsFromImageResourceUrl = (url: string): {type: ImageResourceType; key: string} => { const res = url.match(IMAGES_URL_REGEXP); 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..03ee9e626a 100644 --- a/ui-ngx/src/app/shared/models/time/time.models.ts +++ b/ui-ngx/src/app/shared/models/time/time.models.ts @@ -1125,6 +1125,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 +1139,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 +1163,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/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..8df70edae3 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,44 @@ 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; + } + return dataKeys; +}; + export enum DataLayerPatternType { pattern = 'pattern', @@ -658,10 +684,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; }); }; 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 9fd6e4a71e..3e414577ec 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -228,6 +228,7 @@ import { JsFuncModuleRowComponent } from '@shared/components/js-func-module-row. import { EntityKeyAutocompleteComponent } from '@shared/components/entity/entity-key-autocomplete.component'; import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe'; import { MqttVersionSelectComponent } from '@shared/components/mqtt-version-select.component'; +import { TimeUnitInputComponent } from '@shared/components/time-unit-input.component'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -443,6 +444,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) ScadaSymbolInputComponent, EntityKeyAutocompleteComponent, MqttVersionSelectComponent, + TimeUnitInputComponent, ], imports: [ CommonModule, @@ -707,6 +709,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) ScadaSymbolInputComponent, EntityKeyAutocompleteComponent, MqttVersionSelectComponent, + TimeUnitInputComponent, ] }) export class SharedModule { } diff --git a/ui-ngx/src/assets/dashboard/api_usage.json b/ui-ngx/src/assets/dashboard/api_usage.json index 9738e9ac0f..92c9bdb054 100644 --- a/ui-ngx/src/assets/dashboard/api_usage.json +++ b/ui-ngx/src/assets/dashboard/api_usage.json @@ -79,7 +79,6 @@ } ], "timewindow": { - "hideInterval": false, "hideAggregation": false, "hideAggInterval": false, "selectedTab": 0, @@ -121,779 +120,2158 @@ "widgetCss": "", "pageSize": 1024, "noDataDisplayMessage": "", - "configMode": "basic" + "configMode": "basic", + "borderRadius": "4px" }, "id": "a669cf86-e715-efa4-dd9a-b839abf499e9", "typeFullFqn": "system.cards.timeseries_table" }, - "aab68ab5-8e40-8694-c55c-8eb1c89b88fb": { - "typeFullFqn": "system.cards.markdown_card", - "type": "latest", - "sizeX": 5, - "sizeY": 3.5, + "fa938580-33db-f1b3-fafc-bc3e3784ad57": { + "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, + "entityAliasId": "2e4c97b0-257a-a1b9-690c-141d9bf2ec6f", "dataKeys": [ { - "name": "transportMsgLimit", + "name": "successfulMsgs", "type": "timeseries", - "label": "limit", + "label": "{i18n:api-usage.successful}", "color": "#4caf50", - "settings": {}, - "_hash": 0.5463603803546802, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;", - "aggregationType": "NONE" - }, - { - "name": "transportMsgCount", - "type": "timeseries", - "label": "count", - "color": "#f44336", - "settings": {}, - "_hash": 0.5564241862015964, + "settings": { + "yAxisId": "default", + "showInLegend": true, + "dataHiddenByDefault": false, + "type": "line", + "lineSettings": { + "showLine": true, + "step": false, + "stepType": "start", + "smooth": false, + "lineType": "solid", + "lineWidth": 2.5, + "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)", + "pointShape": "circle", + "pointSize": 12, + "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)", + "backgroundSettings": { + "type": "none", + "opacity": 0.4, + "gradient": { + "start": 100, + "end": 0 + } + } + } + }, + "_hash": 0.15490750967648736, + "aggregationType": null, "units": null, "decimals": null, "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;\n", - "aggregationType": "NONE" + "usePostProcessing": null, + "postFuncBody": null }, { - "name": "transportDataPointsLimit", + "name": "failedMsgs", "type": "timeseries", - "label": "pointsLimit", - "color": "#9c27b0", - "settings": {}, - "_hash": 0.22082255831864894, + "label": "{i18n:api-usage.permanent-failures}", + "color": "#ef5350", + "settings": { + "yAxisId": "default", + "showInLegend": true, + "dataHiddenByDefault": false, + "type": "line", + "lineSettings": { + "showLine": true, + "step": false, + "stepType": "start", + "smooth": false, + "lineType": "solid", + "lineWidth": 2.5, + "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)", + "pointShape": "circle", + "pointSize": 12, + "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)", + "backgroundSettings": { + "type": "none", + "opacity": 0.4, + "gradient": { + "start": 100, + "end": 0 + } + } + } + }, + "_hash": 0.4186621166514697, + "aggregationType": null, "units": null, "decimals": null, "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;", - "aggregationType": "NONE" + "usePostProcessing": null, + "postFuncBody": null }, { - "name": "transportDataPointsCount", + "name": "tmpFailed", "type": "timeseries", - "label": "pointsCount", - "color": "#8bc34a", - "settings": {}, - "_hash": 0.6340356364819146, + "label": "{i18n:api-usage.processing-failures}", + "color": "#ffc107", + "settings": { + "yAxisId": "default", + "showInLegend": true, + "dataHiddenByDefault": false, + "type": "line", + "lineSettings": { + "showLine": true, + "step": false, + "stepType": "start", + "smooth": false, + "lineType": "solid", + "lineWidth": 2.5, + "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)", + "pointShape": "circle", + "pointSize": 12, + "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)", + "backgroundSettings": { + "type": "none", + "opacity": 0.4, + "gradient": { + "start": 100, + "end": 0 + } + } + } + }, + "_hash": 0.49891007198715376, + "aggregationType": null, "units": null, "decimals": null, "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;", - "aggregationType": "NONE" - }, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + }, + "latestDataKeys": [ { - "name": "transportApiState", - "type": "timeseries", - "label": "title", - "color": "#3f51b5", + "name": "queueName", + "type": "entityField", + "label": "Queue name", + "color": "#ffc107", "settings": {}, - "_hash": 0.6894070537030252, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return \"{i18n:api-usage.transport}\";" + "_hash": 0.7021721434431745 }, { - "name": "transportApiState", - "type": "timeseries", - "label": "apiStatus", - "color": "#3f51b5", + "name": "serviceId", + "type": "entityField", + "label": "Service Id", + "color": "#607d8b", "settings": {}, - "_hash": 0.430957831457494, - "aggregationType": "NONE", - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value ? value.toLowerCase() : 'enabled';" - }, - { - "name": "transportApiState", - "type": "timeseries", - "label": "unit", - "color": "#8bc34a", - "settings": {}, - "_hash": 0.662147926074595, - "aggregationType": "NONE", - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return '{i18n:api-usage.messages}';" - }, - { - "name": "transportApiState", - "type": "timeseries", - "label": "pointsUnit", - "color": "#3f51b5", - "settings": {}, - "_hash": 0.44620898738917947, - "aggregationType": "NONE", - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return '{i18n:api-usage.data-points}';" + "_hash": 0.5924381120750077 } - ], - "alarmFilterConfig": { - "statusList": [ - "ACTIVE" - ] - } + ] } ], "timewindow": { - "displayValue": "", + "hideAggregation": false, + "hideAggInterval": false, "selectedTab": 0, "realtime": { - "realtimeType": 1, - "interval": 1000, - "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY" - }, - "history": { - "historyType": 0, - "interval": 1000, - "timewindowMs": 60000, - "fixedTimewindow": { - "startTimeMs": 1708518962586, - "endTimeMs": 1708605362586 - }, - "quickInterval": "CURRENT_DAY" + "timewindowMs": 3600000, + "interval": 1000 }, "aggregation": { - "type": "AVG", - "limit": 25000 + "type": "NONE", + "limit": 10000 } }, - "showTitle": false, - "backgroundColor": "#fff", + "showTitle": true, + "backgroundColor": "#FFFFFF", "color": "rgba(0, 0, 0, 0.87)", "padding": "0px", "settings": { - "useMarkdownTextFunction": true, - "markdownTextPattern": "", - "markdownTextFunction": "function toShortNumber(number) {\n const rounder = Math.pow(10, 1);\n const 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 let key = '';\n for (const power of powers) {\n const reduced = number / power.value;\n for (const power of powers) {\n let reduced = number / power.value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n number = reduced;\n key = power.key;\n break;\n }\n }\n }\n \n return number + key;\n}\n\nfunction calculateBarValues(count, limit) {\n let apiUsageBar = '0%';\n let apiUsagePercent = '';\n let apiUsageValue = `${toShortNumber(count)} / ∞`;\n if (Number.isFinite(limit) && limit > 0) {\n var percent = Math.min(100, ((count / limit) * 100));\n apiUsageBar = `${percent}%`\n apiUsagePercent = `${percent.toFixed(2)}%`;\n apiUsageValue = `${toShortNumber(count)} / ${toShortNumber(limit)}`;\n }\n \n return [apiUsageBar, apiUsagePercent, apiUsageValue]\n}\n\nconst [apiUsageBar, apiUsagePercent, apiUsageValue] = calculateBarValues(data[0].count, data[0].limit);\nconst [apiUsageBar2, apiUsagePercent2, apiUsageValue2] = calculateBarValues(data[0].pointsCount, data[0].pointsLimit);\n\n\nreturn `
` +\n '
' +\n '
' +\n '
' +\n '
' +\n `${data[0].title}` +\n '
' +\n `
${data[0].apiStatus.toUpperCase()}
` +\n '
' +\n '
' +\n '
' +\n '
' +\n `
${data[0].unit}
` +\n '
' +\n `
` +\n '
' +\n '
' +\n `
${apiUsagePercent}
` +\n '
' +\n `
${apiUsageValue}
` +\n '
' +\n '
' +\n '
' +\n '
' +\n '
' +\n `
${data[0].pointsUnit}
` +\n '
' +\n `
` +\n '
' +\n '
' +\n `
${apiUsagePercent2}
` +\n '
' +\n `
${apiUsageValue2}
` +\n '
' +\n '
' + \n '
' +\n '
' +\n '
' +\n '
' +\n '' +\n '
'+\n '' +\n '
' +\n '
'\n", - "applyDefaultMarkdownStyle": false, - "markdownCss": "\n" - }, - "title": "Transport", - "showTitleIcon": false, - "iconColor": "rgba(0, 0, 0, 0.87)", - "iconSize": "24px", - "titleTooltip": "", - "dropShadow": true, - "enableFullscreen": false, - "widgetStyle": {}, - "titleStyle": { - "fontSize": "16px", - "fontWeight": 400 - }, - "showLegend": false, - "useDashboardTimewindow": true, - "displayTimewindow": true, - "widgetCss": "", - "pageSize": 1024, - "noDataDisplayMessage": "", - "actions": { - "elementClick": [ - { - "name": "transport_details", - "icon": "insert_chart", - "useShowWidgetActionFunction": null, - "showWidgetActionFunction": "return true;", - "type": "openDashboardState", - "targetDashboardStateId": "transport", - "setEntityId": false, - "stateEntityParamName": null, - "openRightLayout": false, - "openInSeparateDialog": false, - "openInPopover": false, - "id": "a60e09be-1bea-dfc3-6abb-f87e73256899" - } - ] - } - }, - "row": 0, - "col": 0, - "id": "aab68ab5-8e40-8694-c55c-8eb1c89b88fb" - }, - "a84fa70a-ddfa-3b24-9aa4-cf9ce91f919a": { - "typeFullFqn": "system.cards.markdown_card", - "type": "latest", - "sizeX": 5, - "sizeY": 3.5, - "config": { - "datasources": [ - { - "type": "entity", - "name": null, - "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", - "filterId": null, - "dataKeys": [ - { - "name": "ruleEngineApiState", - "type": "timeseries", - "label": "apiStatus", - "color": "#2196f3", - "settings": {}, - "_hash": 0.8830669138660703, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value ? value : 'enabled';", - "aggregationType": "NONE" - }, - { - "name": "ruleEngineExecutionLimit", - "type": "timeseries", - "label": "limit", - "color": "#4caf50", - "settings": {}, - "_hash": 0.5463603803546802, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;", - "aggregationType": "NONE" - }, - { - "name": "ruleEngineExecutionCount", - "type": "timeseries", - "label": "count", - "color": "#f44336", - "settings": {}, - "_hash": 0.5564241862015964, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;", - "aggregationType": "NONE" + "yAxes": { + "default": { + "units": null, + "decimals": 0, + "show": true, + "label": "", + "labelFont": { + "family": "Roboto", + "size": 12, + "sizeUnit": "px", + "style": "normal", + "weight": "600", + "lineHeight": "1" }, - { - "name": "ruleEngineApiState", - "type": "timeseries", - "label": "title", - "color": "#9c27b0", - "settings": {}, - "_hash": 0.3551317421302518, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return \"{i18n:api-usage.rule-engine}\";" + "labelColor": "rgba(0, 0, 0, 0.54)", + "position": "left", + "showTickLabels": true, + "tickLabelFont": { + "family": "Roboto", + "size": 12, + "sizeUnit": "px", + "style": "normal", + "weight": "400", + "lineHeight": "1" }, - { - "name": "ruleEngineApiState", - "type": "timeseries", - "label": "unit", - "color": "#8bc34a", - "settings": {}, - "_hash": 0.5100381746798048, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return \"{i18n:api-usage.executions}\";" - } - ], - "alarmFilterConfig": { - "statusList": [ - "ACTIVE" - ] + "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 } - } - ], - "timewindow": { - "displayValue": "", - "selectedTab": 0, - "realtime": { - "realtimeType": 1, - "interval": 1000, - "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY" }, - "history": { - "historyType": 0, - "interval": 1000, - "timewindowMs": 60000, - "fixedTimewindow": { - "startTimeMs": 1708518962586, - "endTimeMs": 1708605362586 + "thresholds": [], + "dataZoom": false, + "stack": false, + "xAxis": { + "show": true, + "label": "", + "labelFont": { + "family": "Roboto", + "size": 12, + "sizeUnit": "px", + "style": "normal", + "weight": "600", + "lineHeight": "1" }, - "quickInterval": "CURRENT_DAY" + "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)" }, - "aggregation": { - "type": "AVG", - "limit": 25000 - } - }, - "showTitle": false, - "backgroundColor": "#fff", - "color": "rgba(0, 0, 0, 0.87)", - "padding": "0px", - "settings": { - "useMarkdownTextFunction": true, - "markdownTextPattern": "", - "markdownTextFunction": "function toShortNumber(number) {\n const rounder = Math.pow(10, 1);\n const 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 let key = '';\n for (const power of powers) {\n let reduced = number / power.value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n number = reduced;\n key = power.key;\n break;\n }\n }\n \n return number + key;\n}\n\nfunction calculateBarValues(count, limit) {\n let apiUsageBar = '0%';\n let apiUsagePercent = '';\n let apiUsageValue = `${toShortNumber(count)} / ∞`;\n if (Number.isFinite(limit) && limit > 0) {\n var percent = Math.min(100, ((count / limit) * 100));\n apiUsageBar = `${percent}%`\n apiUsagePercent = `${percent.toFixed(2)}%`;\n apiUsageValue = `${toShortNumber(count)} / ${toShortNumber(limit)}`;\n }\n \n return [apiUsageBar, apiUsagePercent, apiUsageValue]\n}\n\nconst [apiUsageBar, apiUsagePercent, apiUsageValue] = calculateBarValues(data[0].count, data[0].limit);\n\n\nreturn `
` +\n '
' +\n '
' +\n '
' +\n '
${title}
' +\n `
${data[0].apiStatus.toUpperCase()}
` +\n '
' +\n '
' +\n `
${data[0].unit}
` +\n '
' +\n `
` +\n '
' +\n '
' +\n `
${apiUsagePercent}
` +\n '
' +\n `
${apiUsageValue}
` +\n '
' +\n '
' +\n '
' +\n '
' +\n '' +\n '
'+\n '' +\n '' +\n '
' +\n '
'\n", - "applyDefaultMarkdownStyle": false, - "markdownCss": "\n" - }, - "title": "Rule Engine execution", - "showTitleIcon": false, - "iconColor": "rgba(0, 0, 0, 0.87)", - "iconSize": "24px", - "titleTooltip": "", - "dropShadow": true, - "enableFullscreen": false, - "widgetStyle": {}, - "titleStyle": { - "fontSize": "16px", - "fontWeight": 400 - }, - "showLegend": false, - "useDashboardTimewindow": true, - "displayTimewindow": true, - "widgetCss": "", - "pageSize": 1024, - "noDataDisplayMessage": "", - "actions": { - "elementClick": [ - { - "name": "rule_engine_execution_details", - "icon": "insert_chart", - "type": "openDashboardState", - "targetDashboardStateId": "rule_engine_execution", - "setEntityId": false, - "stateEntityParamName": null, - "openRightLayout": false, - "id": "3c30248f-0cd8-fb97-a917-bc1e09984a79" + "noAggregationBarWidthSettings": { + "strategy": "group", + "groupWidth": { + "relative": false, + "relativeWidth": 2, + "absoluteWidth": 1800000 }, - { - "name": "rule_engine_statistics_details", - "icon": "show_chart", - "type": "openDashboardState", - "targetDashboardStateId": "rule_engine_statistics", - "setEntityId": false, - "stateEntityParamName": null, - "openRightLayout": false, - "id": "04e4565a-9e24-23df-f376-f2ec70a8165f" + "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": true, + "showMax": true, + "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, + "tooltipHideZeroValues": true, + "tooltipBackgroundColor": "rgba(255, 255, 255, 0.76)", + "tooltipBackgroundBlur": 4, + "animation": { + "animation": true, + "animationThreshold": 2000, + "animationDuration": 300, + "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 + } + }, + "padding": "12px", + "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)" + }, + "title": "{i18n:api-usage.queue-stats}", + "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": "a84fa70a-ddfa-3b24-9aa4-cf9ce91f919a" + "id": "fa938580-33db-f1b3-fafc-bc3e3784ad57" }, - "d70d26d4-e22d-4ca9-9ea7-f9c87c093321": { - "typeFullFqn": "system.cards.markdown_card", - "type": "latest", - "sizeX": 5, - "sizeY": 3.5, + "2ee89893-4e38-5331-95b7-3fd4f310c5a7": { + "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, + "entityAliasId": "2e4c97b0-257a-a1b9-690c-141d9bf2ec6f", "dataKeys": [ { - "name": "jsExecutionApiState", + "name": "timeoutMsgs", "type": "timeseries", - "label": "jsApiState", - "color": "#2196f3", - "settings": {}, - "_hash": 0.8830669138660703, + "label": "{i18n:api-usage.permanent-timeouts}", + "color": "#4caf50", + "settings": { + "yAxisId": "default", + "showInLegend": true, + "dataHiddenByDefault": false, + "type": "line", + "lineSettings": { + "showLine": true, + "step": false, + "stepType": "start", + "smooth": false, + "lineType": "solid", + "lineWidth": 2.5, + "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)", + "pointShape": "circle", + "pointSize": 12, + "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)", + "backgroundSettings": { + "type": "none", + "opacity": 0.4, + "gradient": { + "start": 100, + "end": 0 + } + } + } + }, + "_hash": 0.565222981550328, + "aggregationType": null, "units": null, "decimals": null, "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value ? value : 'ENABLED';", - "aggregationType": "NONE" + "usePostProcessing": null, + "postFuncBody": null }, { - "name": "jsExecutionLimit", + "name": "tmpTimeout", "type": "timeseries", - "label": "jsLimit", - "color": "#4caf50", - "settings": {}, - "_hash": 0.5463603803546802, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;", - "aggregationType": "NONE" - }, - { - "name": "jsExecutionCount", - "type": "timeseries", - "label": "jsCount", - "color": "#f44336", - "settings": {}, - "_hash": 0.5564241862015964, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;", - "aggregationType": "NONE" - }, - { - "name": "jsExecutionApiState", - "type": "timeseries", - "label": "title", + "label": "{i18n:api-usage.processing-timeouts}", "color": "#9c27b0", - "settings": {}, - "_hash": 0.7673280949238444, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return \"{i18n:api-usage.scripts}\";", - "aggregationType": "NONE" - }, - { - "name": "jsExecutionApiState", - "type": "timeseries", - "label": "jsUnit", - "color": "#8bc34a", - "settings": {}, - "_hash": 0.7926918686567068, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return \"{i18n:api-usage.javascript}\";", - "aggregationType": "NONE" - }, - { - "name": "tbelExecutionApiState", - "type": "timeseries", - "label": "tbelApiState", - "color": "#3f51b5", - "settings": {}, - "_hash": 0.2002981454581909, - "aggregationType": "NONE", - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value ? value : 'ENABLED';" - }, - { - "name": "tbelExecutionLimit", - "type": "timeseries", - "label": "tbelLimit", - "color": "#ffeb3b", - "settings": {}, - "_hash": 0.5039854873031677, - "aggregationType": "NONE", - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;" - }, - { - "name": "tbelExecutionCount", - "type": "timeseries", - "label": "tbelCount", - "color": "#e91e63", - "settings": {}, - "_hash": 0.9506731992087107, - "aggregationType": "NONE", - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;" - }, - { - "name": "tbelExecutionApiState", - "type": "timeseries", - "label": "tbelUnit", - "color": "#ffeb3b", - "settings": {}, - "_hash": 0.3673530683177082, - "aggregationType": "NONE", + "settings": { + "yAxisId": "default", + "showInLegend": true, + "dataHiddenByDefault": false, + "type": "line", + "lineSettings": { + "showLine": true, + "step": false, + "stepType": "start", + "smooth": false, + "lineType": "solid", + "lineWidth": 2.5, + "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)", + "pointShape": "circle", + "pointSize": 12, + "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)", + "backgroundSettings": { + "type": "none", + "opacity": 0.4, + "gradient": { + "start": 100, + "end": 0 + } + } + } + }, + "_hash": 0.2679547062508352, + "aggregationType": null, "units": null, "decimals": null, "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return \"{i18n:api-usage.tbel}\";" + "usePostProcessing": null, + "postFuncBody": null } ], "alarmFilterConfig": { "statusList": [ "ACTIVE" ] - } - } - ], - "timewindow": { - "displayValue": "", - "selectedTab": 0, - "realtime": { - "realtimeType": 1, - "interval": 1000, - "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY" - }, - "history": { - "historyType": 0, - "interval": 1000, - "timewindowMs": 60000, - "fixedTimewindow": { - "startTimeMs": 1708518962586, - "endTimeMs": 1708605362586 }, - "quickInterval": "CURRENT_DAY" - }, - "aggregation": { - "type": "AVG", - "limit": 25000 - } - }, - "showTitle": false, - "backgroundColor": "#fff", - "color": "rgba(0, 0, 0, 0.87)", - "padding": "0px", - "settings": { - "useMarkdownTextFunction": true, - "markdownTextPattern": "", - "markdownTextFunction": "function toShortNumber(number) {\n const rounder = Math.pow(10, 1);\n const 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 let key = '';\n for (const power of powers) {\n const reduced = number / power.value;\n for (const power of powers) {\n let reduced = number / power.value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n number = reduced;\n key = power.key;\n break;\n }\n }\n }\n \n return number + key;\n}\n\nfunction calculateBarValues(count, limit) {\n let apiUsageBar = '0%';\n let apiUsagePercent = '';\n let apiUsageValue = `${toShortNumber(count)} / ∞`;\n if (Number.isFinite(limit) && limit > 0) {\n var percent = Math.min(100, ((count / limit) * 100));\n apiUsageBar = `${percent}%`\n apiUsagePercent = `${percent.toFixed(2)}%`;\n apiUsageValue = `${toShortNumber(count)} / ${toShortNumber(limit)}`;\n }\n \n return [apiUsageBar, apiUsagePercent, apiUsageValue]\n}\n\nconst [jsUsageBar, jsUsagePercent, jsUsageValue] = calculateBarValues(data[0].jsCount, data[0].jsLimit);\nconst [tbelUsageBar, tbelUsagePercent, tbelUsageValue] = calculateBarValues(data[0].tbelCount, data[0].tbelLimit);\n\nconst jsApiState = data[0].jsApiState;\nconst tbelApiState = data[0].tbelApiState;\nlet currentState;\nif (jsApiState === 'DISABLED' || tbelApiState === 'DISABLED') {\n currentState = 'DISABLED';\n} else if (jsApiState === 'WARNING' || tbelApiState === 'WARNING') {\n currentState = 'WARNING';\n} else {\n currentState = 'ENABLED';\n}\nconst cardClass = currentState.toLowerCase()\n\nreturn `
` +\n '
' +\n '
' +\n '
' +\n '
' +\n `${data[0].title}` +\n '
' +\n `
${currentState}
` +\n '
' +\n '
' +\n '
' +\n '
' +\n `
${data[0].jsUnit}
` +\n '
' +\n `
` +\n '
' +\n '
' +\n `
${jsUsagePercent}
` +\n '
' +\n `
${jsUsageValue}
` +\n '
' +\n '
' +\n '
' +\n '
' +\n '
' +\n `
${data[0].tbelUnit}
` +\n '
' +\n `
` +\n '
' +\n '
' +\n `
${tbelUsagePercent}
` +\n '
' +\n `
${tbelUsageValue}
` +\n '
' +\n '
' + \n '
' +\n '
' +\n '
' +\n '
' +\n '' +\n '
'+\n '' +\n '
' +\n '
'\n", - "applyDefaultMarkdownStyle": false, - "markdownCss": "\n" - }, - "title": "JavaScript functions", - "showTitleIcon": false, - "iconColor": "rgba(0, 0, 0, 0.87)", - "iconSize": "24px", - "titleTooltip": "", - "dropShadow": true, - "enableFullscreen": false, - "widgetStyle": {}, - "titleStyle": { - "fontSize": "16px", - "fontWeight": 400 - }, - "showLegend": false, - "useDashboardTimewindow": true, - "displayTimewindow": true, - "widgetCss": "", - "pageSize": 1024, - "noDataDisplayMessage": "", - "actions": { - "elementClick": [ - { - "name": "script_functions_details", - "icon": "insert_chart", - "useShowWidgetActionFunction": null, - "showWidgetActionFunction": "return true;", - "type": "openDashboardState", - "targetDashboardStateId": "script_functions", - "setEntityId": false, - "stateEntityParamName": null, - "openRightLayout": false, - "openInSeparateDialog": false, - "openInPopover": false, - "id": "d4961bea-84de-e1af-e50f-666b98d34cd5" - } - ] - } - }, - "row": 0, - "col": 0, - "id": "d70d26d4-e22d-4ca9-9ea7-f9c87c093321" - }, - "4d3ea95c-3188-9872-1817-2f989c7729e0": { - "typeFullFqn": "system.cards.markdown_card", - "type": "latest", - "sizeX": 5, - "sizeY": 3.5, - "config": { - "datasources": [ - { - "type": "entity", - "name": null, - "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", - "filterId": null, - "dataKeys": [ - { - "name": "storageDataPointsLimit", - "type": "timeseries", - "label": "limit", - "color": "#4caf50", - "settings": {}, - "_hash": 0.5463603803546802, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;", - "aggregationType": "NONE" - }, + "latestDataKeys": [ { - "name": "storageDataPointsCount", - "type": "timeseries", - "label": "count", + "name": "queueName", + "type": "entityField", + "label": "Queue name", "color": "#f44336", "settings": {}, - "_hash": 0.5564241862015964, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;", - "aggregationType": "NONE" + "_hash": 0.7066844328378095 }, { - "name": "dbApiState", - "type": "timeseries", - "label": "apiStatus", + "name": "serviceId", + "type": "entityField", + "label": "Service Id", "color": "#ffc107", "settings": {}, - "_hash": 0.8737107059960671, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value ? value : 'enabled';", - "aggregationType": "NONE" - }, - { - "name": "dbApiState", - "type": "timeseries", - "label": "title", - "color": "#9c27b0", - "settings": {}, - "_hash": 0.6301889725474652, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return \"{i18n:api-usage.telemetry}\";" - }, - { - "name": "dbApiState", - "type": "timeseries", - "label": "unit", - "color": "#8bc34a", - "settings": {}, - "_hash": 0.0027742924142306613, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return \"{i18n:api-usage.data-points-storage-days}\";" + "_hash": 0.1371570237026627 } - ], - "alarmFilterConfig": { - "statusList": [ - "ACTIVE" - ] - } + ] } ], "timewindow": { - "displayValue": "", + "hideAggregation": false, + "hideAggInterval": false, "selectedTab": 0, "realtime": { - "realtimeType": 1, - "interval": 1000, - "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY" + "timewindowMs": 3600000, + "interval": 1000 }, - "history": { - "historyType": 0, - "interval": 1000, - "timewindowMs": 60000, - "fixedTimewindow": { - "startTimeMs": 1708518962586, - "endTimeMs": 1708605362586 - }, - "quickInterval": "CURRENT_DAY" + "aggregation": { + "type": "NONE", + "limit": 10000 + } + }, + "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": false, + "relativeWidth": 2, + "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": true, + "showMax": true, + "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, + "tooltipHideZeroValues": true, + "tooltipBackgroundColor": "rgba(255, 255, 255, 0.76)", + "tooltipBackgroundBlur": 4, + "animation": { + "animation": true, + "animationThreshold": 2000, + "animationDuration": 300, + "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 + } + }, + "padding": "12px", + "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)" + }, + "title": "{i18n:api-usage.processing-failures-and-timeouts}", + "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": "2ee89893-4e38-5331-95b7-3fd4f310c5a7" + }, + "85240e8c-7af7-90a9-ad0a-726013c479a6": { + "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": "transportMsgCountHourly", + "type": "timeseries", + "label": "{i18n:api-usage.transport-messages}", + "color": "#2196f3", + "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" + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": false, + "postFuncBody": null, + "aggregationType": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + } + ], + "timewindow": { + "selectedTab": 0, + "realtime": { + "realtimeType": 0, + "interval": 3600000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 50000 + } + }, + "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": null, + "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": 3600000 + }, + "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-messages-hourly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": null, + "configMode": "basic", + "actions": { + "headerButton": [] + }, + "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": "85240e8c-7af7-90a9-ad0a-726013c479a6" + }, + "d0a10a8f-8f48-f9d6-8306-d12af9b49690": { + "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" + }, + "_hash": 0.46849996721308895, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + } + ], + "timewindow": { + "selectedTab": 0, + "realtime": { + "realtimeType": 0, + "interval": 3600000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 50000 + } + }, + "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": null, + "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": 3600000 + }, + "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-point-hourly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": null, + "configMode": "basic", + "actions": { + "headerButton": [] + }, + "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": "d0a10a8f-8f48-f9d6-8306-d12af9b49690" + }, + "4544080d-9b6f-b592-9cd4-0e0335d33857": { + "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": { + "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)", + "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)", + "backgroundSettings": { + "type": "none", + "opacity": 0.4, + "gradient": { + "start": 100, + "end": 0 + } + } + } + }, + "_hash": 0.0661644137210089, + "units": null, + "decimals": null, + "funcBody": null, + "usePostProcessing": null, + "postFuncBody": null, + "aggregationType": null + } + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } + } + ], + "timewindow": { + "selectedTab": 0, + "realtime": { + "realtimeType": 0, + "interval": 3600000, + "timewindowMs": 86400000 }, "aggregation": { - "type": "AVG", - "limit": 25000 + "type": "NONE", + "limit": 50000 } }, - "showTitle": false, - "backgroundColor": "#fff", + "showTitle": true, + "backgroundColor": "#FFFFFF", "color": "rgba(0, 0, 0, 0.87)", "padding": "0px", "settings": { - "useMarkdownTextFunction": true, - "markdownTextPattern": "", - "markdownTextFunction": "function toShortNumber(number) {\n const rounder = Math.pow(10, 1);\n const 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 let key = '';\n for (const power of powers) {\n let reduced = number / power.value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n number = reduced;\n key = power.key;\n break;\n }\n }\n \n return number + key;\n}\n\nfunction calculateBarValues(count, limit) {\n let apiUsageBar = '0%';\n let apiUsagePercent = '';\n let apiUsageValue = `${toShortNumber(count)} / ∞`;\n if (Number.isFinite(limit) && limit > 0) {\n var percent = Math.min(100, ((count / limit) * 100));\n apiUsageBar = `${percent}%`\n apiUsagePercent = `${percent.toFixed(2)}%`;\n apiUsageValue = `${toShortNumber(count)} / ${toShortNumber(limit)}`;\n }\n \n return [apiUsageBar, apiUsagePercent, apiUsageValue]\n}\n\nconst [apiUsageBar, apiUsagePercent, apiUsageValue] = calculateBarValues(data[0].count, data[0].limit);\n\n\nreturn `
` +\n '
' +\n '
' +\n '
' +\n '
${title}
' +\n `
${data[0].apiStatus.toUpperCase()}
` +\n '
' +\n '
' +\n `
${data[0].unit}
` +\n '
' +\n `
` +\n '
' +\n '
' +\n `
${apiUsagePercent}
` +\n '
' +\n `
${apiUsageValue}
` +\n '
' +\n '
' +\n '
' +\n '
' +\n '' +\n '
'+\n '' +\n '
' +\n '
'\n", - "applyDefaultMarkdownStyle": false, - "markdownCss": "\n" + "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": 3600000 + }, + "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": "Telemetry persistence", - "showTitleIcon": false, - "iconColor": "rgba(0, 0, 0, 0.87)", - "iconSize": "24px", - "titleTooltip": "", + "title": "{i18n:api-usage.rule-engine-hourly-activity}", "dropShadow": true, - "enableFullscreen": false, - "widgetStyle": {}, - "titleStyle": { - "fontSize": "16px", - "fontWeight": 400 - }, - "showLegend": false, - "useDashboardTimewindow": true, - "displayTimewindow": true, - "widgetCss": "", - "pageSize": 1024, - "noDataDisplayMessage": "", + "enableFullscreen": true, + "titleStyle": null, + "configMode": "basic", "actions": { - "elementClick": [ + "headerButton": [ { - "name": "telemetry_persistence_details", - "icon": "insert_chart", + "name": "{i18n:api-usage.view-statistics}", + "icon": "show_chart", "type": "openDashboardState", - "targetDashboardStateId": "telemetry_persistence", + "targetDashboardStateId": "rule_engine_statistics", "setEntityId": false, "stateEntityParamName": null, "openRightLayout": false, - "id": "6248831c-5b3f-8879-8548-afcf43f10610" + "id": "f9f08190-9ed9-d802-5b7a-c57ff84b5648" } ] - } + }, + "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": "4d3ea95c-3188-9872-1817-2f989c7729e0" + "id": "4544080d-9b6f-b592-9cd4-0e0335d33857" }, - "2d0d6ff6-cd59-51d4-b916-38e22cdd0702": { - "typeFullFqn": "system.cards.markdown_card", - "type": "latest", - "sizeX": 5, - "sizeY": 3.5, + "5d0f2f57-499d-1324-8e1b-cfbc0b3149d2": { + "typeFullFqn": "system.time_series_chart", + "type": "timeseries", + "sizeX": 8, + "sizeY": 5, "config": { "datasources": [ { @@ -903,72 +2281,76 @@ "filterId": null, "dataKeys": [ { - "name": "createdAlarmsLimit", - "type": "timeseries", - "label": "limit", - "color": "#4caf50", - "settings": {}, - "_hash": 0.5463603803546802, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;", - "aggregationType": "NONE" - }, - { - "name": "createdAlarmsCount", - "type": "timeseries", - "label": "count", - "color": "#f44336", - "settings": {}, - "_hash": 0.5564241862015964, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;", - "aggregationType": "NONE" - }, - { - "name": "alarmApiState", - "type": "timeseries", - "label": "apiStatus", - "color": "#ffc107", - "settings": {}, - "_hash": 0.8737107059960671, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value ? value : 'enabled';", - "aggregationType": "NONE" - }, - { - "name": "alarmApiState", - "type": "timeseries", - "label": "title", - "color": "#9c27b0", - "settings": {}, - "_hash": 0.43439375716502227, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return \"{i18n:api-usage.alarm}\";" - }, - { - "name": "alarmApiState", + "name": "storageDataPointsCountHourly", "type": "timeseries", - "label": "unit", - "color": "#8bc34a", - "settings": {}, - "_hash": 0.9964061963495883, + "label": "{i18n:api-usage.data-points-storage-days}", + "color": "#1039ee", + "settings": { + "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)", + "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)", + "backgroundSettings": { + "type": "none", + "opacity": 0.4, + "gradient": { + "start": 100, + "end": 0 + } + } + } + }, + "_hash": 0.0661644137210089, "units": null, "decimals": null, "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return \"{i18n:api-usage.alarms-created}\";" + "usePostProcessing": null, + "postFuncBody": null, + "aggregationType": null } ], "alarmFilterConfig": { @@ -976,221 +2358,353 @@ "ACTIVE" ] } - } - ], - "timewindow": { - "displayValue": "", - "selectedTab": 0, - "realtime": { - "realtimeType": 1, - "interval": 1000, - "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY" + } + ], + "timewindow": { + "selectedTab": 0, + "realtime": { + "realtimeType": 0, + "interval": 3600000, + "timewindowMs": 86400000 + }, + "aggregation": { + "type": "NONE", + "limit": 50000 + } + }, + "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": 3600000 + }, + "barWidth": { + "relative": true, + "relativeWidth": 2, + "absoluteWidth": 1000 + } }, - "history": { - "historyType": 0, - "interval": 1000, - "timewindowMs": 60000, - "fixedTimewindow": { - "startTimeMs": 1708518962586, - "endTimeMs": 1708605362586 + "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" }, - "quickInterval": "CURRENT_DAY" + "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)" }, - "aggregation": { - "type": "AVG", - "limit": 25000 - } + "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" }, - "showTitle": false, - "backgroundColor": "#fff", - "color": "rgba(0, 0, 0, 0.87)", - "padding": "0px", - "settings": { - "useMarkdownTextFunction": true, - "markdownTextPattern": "", - "markdownTextFunction": "function toShortNumber(number) {\n const rounder = Math.pow(10, 1);\n const 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 let key = '';\n for (const power of powers) {\n let reduced = number / power.value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n number = reduced;\n key = power.key;\n break;\n }\n }\n \n return number + key;\n}\n\nfunction calculateBarValues(count, limit) {\n let apiUsageBar = '0%';\n let apiUsagePercent = '';\n let apiUsageValue = `${toShortNumber(count)} / ∞`;\n if (Number.isFinite(limit) && limit > 0) {\n var percent = Math.min(100, ((count / limit) * 100));\n apiUsageBar = `${percent}%`\n apiUsagePercent = `${percent.toFixed(2)}%`;\n apiUsageValue = `${toShortNumber(count)} / ${toShortNumber(limit)}`;\n }\n \n return [apiUsageBar, apiUsagePercent, apiUsageValue]\n}\n\nconst [apiUsageBar, apiUsagePercent, apiUsageValue] = calculateBarValues(data[0].count, data[0].limit);\n\n\nreturn `
` +\n '
' +\n '
' +\n '
' +\n '
${title}
' +\n `
${data[0].apiStatus.toUpperCase()}
` +\n '
' +\n '
' +\n `
${data[0].unit}
` +\n '
' +\n `
` +\n '
' +\n '
' +\n `
${apiUsagePercent}
` +\n '
' +\n `
${apiUsageValue}
` +\n '
' +\n '
' +\n '
' +\n '
' +\n '' +\n '
'+\n '' +\n '
' +\n '
'\n", - "applyDefaultMarkdownStyle": false, - "markdownCss": "\n" + "title": "{i18n:api-usage.data-points-storage-days-hourly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": null, + "configMode": "basic", + "actions": { + "headerButton": [] }, - "title": "Alarm created", "showTitleIcon": false, - "iconColor": "rgba(0, 0, 0, 0.87)", - "iconSize": "24px", + "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": "", - "dropShadow": true, - "enableFullscreen": false, "widgetStyle": {}, - "titleStyle": { - "fontSize": "16px", - "fontWeight": 400 - }, - "showLegend": false, - "useDashboardTimewindow": true, - "displayTimewindow": true, "widgetCss": "", "pageSize": 1024, + "units": "", + "decimals": null, "noDataDisplayMessage": "", - "actions": { - "elementClick": [ - { - "name": "email_messages_details", - "icon": "insert_chart", - "type": "openDashboardState", - "targetDashboardStateId": "alarms_created", - "setEntityId": false, - "stateEntityParamName": null, - "openInSeparateDialog": null, - "dialogTitle": null, - "dialogHideDashboardToolbar": true, - "dialogWidth": null, - "dialogHeight": null, - "openRightLayout": false, - "id": "946ba769-84ac-1507-6baa-94701de8967b" - } - ] - } + "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": "2d0d6ff6-cd59-51d4-b916-38e22cdd0702" - }, - "120573cc-e246-eb49-7d80-68e5d3b3c0cc": { - "typeFullFqn": "system.cards.markdown_card", - "type": "latest", - "sizeX": 5, - "sizeY": 3.5, - "config": { - "datasources": [ - { - "type": "entity", - "name": null, - "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", - "filterId": null, - "dataKeys": [ - { - "name": "emailApiState", - "type": "timeseries", - "label": "apiState", - "color": "#2196f3", - "settings": {}, - "_hash": 0.8830669138660703, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": null, - "postFuncBody": null - }, - { - "name": "emailLimit", - "type": "timeseries", - "label": "limit", - "color": "#4caf50", - "settings": {}, - "_hash": 0.5463603803546802, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;", - "aggregationType": "NONE" - }, - { - "name": "emailCount", - "type": "timeseries", - "label": "count", - "color": "#f44336", - "settings": {}, - "_hash": 0.5564241862015964, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;", - "aggregationType": "NONE" - }, + "id": "5d0f2f57-499d-1324-8e1b-cfbc0b3149d2" + }, + "fb155957-1af4-233e-e2fb-09e648e75d6e": { + "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": "smsApiState", + "name": "transportMsgCountHourly", "type": "timeseries", - "label": "apiStatePoint", - "color": "#e91e63", - "settings": {}, - "_hash": 0.2969682764607864, + "label": "{i18n:api-usage.transport-messages}", + "color": "#2196f3", + "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" + }, + "_hash": 0.0661644137210089, "units": null, "decimals": null, "funcBody": null, "usePostProcessing": null, "postFuncBody": null - }, - { - "name": "smsLimit", - "type": "timeseries", - "label": "pointsLimit", - "color": "#9c27b0", - "settings": {}, - "_hash": 0.22082255831864894, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;", - "aggregationType": "NONE" - }, - { - "name": "smsCount", - "type": "timeseries", - "label": "pointsCount", - "color": "#8bc34a", - "settings": {}, - "_hash": 0.6340356364819146, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return value !== '' ? parseInt(value, 10) : 0;", - "aggregationType": "NONE" - }, - { - "name": "notificationApiState", - "type": "timeseries", - "label": "title", - "color": "#3f51b5", - "settings": {}, - "_hash": 0.6894070537030252, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return \"{i18n:api-usage.notifications}\";", - "aggregationType": "NONE" - }, - { - "name": "notificationApiState", - "type": "timeseries", - "label": "unit", - "color": "#3f51b5", - "settings": {}, - "_hash": 0.0005447336528170421, - "aggregationType": "NONE", - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return '{i18n:api-usage.email}';" - }, - { - "name": "notificationApiState", - "type": "timeseries", - "label": "pointsUnit", - "color": "#e91e63", - "settings": {}, - "_hash": 0.12117146988088967, - "aggregationType": "NONE", - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": true, - "postFuncBody": "return '{i18n:api-usage.sms}';" } ], "alarmFilterConfig": { @@ -1201,83 +2715,310 @@ } ], "timewindow": { - "displayValue": "", - "selectedTab": 0, - "realtime": { - "realtimeType": 1, - "interval": 1000, - "timewindowMs": 60000, + "hideAggregation": false, + "hideAggInterval": false, + "hideTimezone": false, + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 2592000000, + "interval": 86400000, + "fixedTimewindow": { + "startTimeMs": 1709729389667, + "endTimeMs": 1709815789667 + }, "quickInterval": "CURRENT_DAY" }, - "history": { - "historyType": 0, - "interval": 1000, - "timewindowMs": 60000, - "fixedTimewindow": { - "startTimeMs": 1708518962586, - "endTimeMs": 1708605362586 + "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" }, - "quickInterval": "CURRENT_DAY" + "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)" }, - "aggregation": { - "type": "AVG", - "limit": 25000 - } - }, - "showTitle": false, - "backgroundColor": "#fff", - "color": "rgba(0, 0, 0, 0.87)", - "padding": "0px", - "settings": { - "useMarkdownTextFunction": true, - "markdownTextPattern": "", - "markdownTextFunction": "function toShortNumber(number) {\n const rounder = Math.pow(10, 1);\n const 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 let key = '';\n for (const power of powers) {\n const reduced = number / power.value;\n for (const power of powers) {\n let reduced = number / power.value;\n reduced = Math.round(reduced * rounder) / rounder;\n if (reduced >= 1) {\n number = reduced;\n key = power.key;\n break;\n }\n }\n }\n \n return number + key;\n}\n\nfunction calculateBarValues(count, limit) {\n let apiUsageBar = '0%';\n let apiUsagePercent = '';\n let apiUsageValue = `${toShortNumber(count)} / ∞`;\n if (Number.isFinite(limit) && limit > 0) {\n var percent = Math.min(100, ((count / limit) * 100));\n apiUsageBar = `${percent}%`\n apiUsagePercent = `${percent.toFixed(2)}%`;\n apiUsageValue = `${toShortNumber(count)} / ${toShortNumber(limit)}`;\n }\n \n return [apiUsageBar, apiUsagePercent, apiUsageValue]\n}\n\nconst [apiUsageBar, apiUsagePercent, apiUsageValue] = calculateBarValues(data[0].count, data[0].limit);\nconst [apiUsageBar2, apiUsagePercent2, apiUsageValue2] = calculateBarValues(data[0].pointsCount, data[0].pointsLimit);\n\nconst apiState = data[0].apiState;\nconst apiStatePoint = data[0].apiStatePoint;\nlet currentState;\nif (apiState === 'DISABLED' || apiStatePoint === 'DISABLED') {\n currentState = 'DISABLED';\n} else if (apiState === 'WARNING' || apiStatePoint === 'WARNING') {\n currentState = 'WARNING';\n} else {\n currentState = 'ENABLED';\n}\n\n\n\nreturn `
` +\n '
' +\n '
' +\n '
' +\n '
' +\n `${data[0].title}` +\n '
' +\n `
${currentState}
` +\n '
' +\n '
' +\n '
' +\n '
' +\n `
${data[0].unit}
` +\n '
' +\n `
` +\n '
' +\n '
' +\n `
${apiUsagePercent}
` +\n '
' +\n `
${apiUsageValue}
` +\n '
' +\n '
' +\n '
' +\n '
' +\n '
' +\n `
${data[0].pointsUnit}
` +\n '
' +\n `
` +\n '
' +\n '
' +\n `
${apiUsagePercent2}
` +\n '
' +\n `
${apiUsageValue2}
` +\n '
' +\n '
' + \n '
' +\n '
' +\n '
' +\n '
' +\n '' +\n '
'+\n '' +\n '
' +\n '
'\n", - "applyDefaultMarkdownStyle": false, - "markdownCss": "\n" + "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": "Notifications (Email/SMS)", + "title": "{i18n:api-usage.transport-msg-daily-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": null, + "configMode": "basic", + "actions": {}, "showTitleIcon": false, - "iconColor": "rgba(0, 0, 0, 0.87)", - "iconSize": "24px", + "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": "", - "dropShadow": true, - "enableFullscreen": false, "widgetStyle": {}, - "titleStyle": { - "fontSize": "16px", - "fontWeight": 400 - }, - "showLegend": false, - "useDashboardTimewindow": true, - "displayTimewindow": true, "widgetCss": "", "pageSize": 1024, + "units": "", + "decimals": null, "noDataDisplayMessage": "", - "actions": { - "elementClick": [ - { - "name": "transport_details", - "icon": "insert_chart", - "type": "openDashboardState", - "targetDashboardStateId": "notifications", - "setEntityId": false, - "stateEntityParamName": null, - "openInSeparateDialog": null, - "dialogTitle": null, - "dialogHideDashboardToolbar": true, - "dialogWidth": null, - "dialogHeight": null, - "openRightLayout": false, - "id": "46b7cefe-e1f2-67c1-4055-3a214520f869" - } - ] - } + "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": "120573cc-e246-eb49-7d80-68e5d3b3c0cc" + "id": "fb155957-1af4-233e-e2fb-09e648e75d6e" }, - "63f99d90-23ab-f8c2-3290-1e693ded5a2e": { + "4817e33b-87be-5be3-eaca-ca68a2eb4e0c": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -1323,71 +3064,40 @@ "units": null, "decimals": null, "funcBody": null, - "usePostProcessing": false, - "postFuncBody": null, - "aggregationType": null - }, - { - "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" - }, - "_hash": 0.46849996721308895, - "units": null, - "decimals": null, - "funcBody": null, "usePostProcessing": null, "postFuncBody": null } - ], - "alarmFilterConfig": { - "statusList": [ - "ACTIVE" - ] - } + ] } ], "timewindow": { - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false, "hideAggregation": false, "hideAggInterval": false, "hideTimezone": false, - "selectedTab": 0, + "selectedTab": 1, "realtime": { "realtimeType": 0, - "timewindowMs": 86400000, + "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", - "interval": 300000 + "hideInterval": false, + "hideLastInterval": false, + "hideFixedInterval": false, + "hideQuickInterval": false }, "aggregation": { "type": "NONE", - "limit": 50000 + "limit": 25000 }, "timezone": null }, @@ -1460,7 +3170,8 @@ "weight": "400", "lineHeight": "1" }, - "tickLabelColor": null, + "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -1471,9 +3182,9 @@ "noAggregationBarWidthSettings": { "strategy": "group", "groupWidth": { - "relative": false, - "relativeWidth": 2, - "absoluteWidth": 3600000 + "relative": true, + "relativeWidth": 6, + "absoluteWidth": 1800000 }, "barWidth": { "relative": true, @@ -1499,7 +3210,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -1516,7 +3228,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -1548,27 +3262,83 @@ "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-hourly-activity}", + "title": "{i18n:api-usage.transport-msg-monthly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, "configMode": "basic", - "actions": { - "headerButton": [ - { - "name": "{i18n:api-usage.view-details}", - "icon": "insert_chart", - "type": "openDashboardState", - "targetDashboardStateId": "transport", - "setEntityId": false, - "stateEntityParamName": null, - "openRightLayout": false, - "id": "6ef12f6a-0266-25cf-6ca5-5dcb772252c6" - } - ] - }, + "actions": {}, "showTitleIcon": false, "titleIcon": "thermostat", "iconColor": "#1F6BDD", @@ -1607,14 +3377,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "63f99d90-23ab-f8c2-3290-1e693ded5a2e" + "id": "4817e33b-87be-5be3-eaca-ca68a2eb4e0c" }, - "a2b7e906-2d8a-41a8-99a6-409531bfa743": { + "79056202-c92b-1dae-ce49-318ec52e2d3b": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -1628,68 +3398,34 @@ "filterId": null, "dataKeys": [ { - "name": "ruleEngineExecutionCountHourly", + "name": "transportDataPointsCountHourly", "type": "timeseries", - "label": "{i18n:api-usage.rule-engine-executions}", - "color": "#ab00ff", + "label": "{i18n:api-usage.transport-data-points}", + "color": "#4CAF50", "settings": { - "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)", - "pointShape": "emptyCircle", - "pointSize": 4, - "fillAreaSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } + "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 }, - "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)", - "backgroundSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } - } - } + "type": "bar", + "yAxisId": "default" }, "_hash": 0.0661644137210089, "units": null, @@ -1708,22 +3444,23 @@ } ], "timewindow": { - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false, "hideAggregation": false, "hideAggInterval": false, "hideTimezone": false, - "selectedTab": 0, - "realtime": { - "realtimeType": 0, - "timewindowMs": 86400000, - "quickInterval": "CURRENT_YEAR", - "interval": 7200000 + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 2592000000, + "interval": 86400000, + "fixedTimewindow": { + "startTimeMs": 1709729389667, + "endTimeMs": 1709815789667 + }, + "quickInterval": "CURRENT_DAY" }, "aggregation": { - "type": "NONE", - "limit": 50000 + "type": "SUM", + "limit": 25000 }, "timezone": null }, @@ -1797,6 +3534,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -1807,9 +3545,9 @@ "noAggregationBarWidthSettings": { "strategy": "group", "groupWidth": { - "relative": false, - "relativeWidth": 2, - "absoluteWidth": 3600000 + "relative": true, + "relativeWidth": 6, + "absoluteWidth": 1800000 }, "barWidth": { "relative": true, @@ -1835,7 +3573,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -1852,7 +3591,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -1884,37 +3625,83 @@ "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}", + "title": "{i18n:api-usage.transport-data-points-daily-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, "configMode": "basic", - "actions": { - "headerButton": [ - { - "name": "{i18n:api-usage.view-statistics}", - "icon": "show_chart", - "type": "openDashboardState", - "targetDashboardStateId": "rule_engine_statistics", - "setEntityId": false, - "stateEntityParamName": null, - "openRightLayout": false, - "id": "f9f08190-9ed9-d802-5b7a-c57ff84b5648" - }, - { - "name": "{i18n:api-usage.view-details}", - "icon": "insert_chart", - "type": "openDashboardState", - "targetDashboardStateId": "rule_engine_execution", - "setEntityId": false, - "stateEntityParamName": null, - "openRightLayout": false, - "id": "1aec196b-44ba-ddf4-c4dc-c3f60c1eb6fc" - } - ] - }, + "actions": {}, "showTitleIcon": false, "titleIcon": "thermostat", "iconColor": "#1F6BDD", @@ -1953,14 +3740,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "a2b7e906-2d8a-41a8-99a6-409531bfa743" + "id": "79056202-c92b-1dae-ce49-318ec52e2d3b" }, - "ca996b66-ab7e-f977-152c-98e4ebf2a901": { + "966ffee7-ba0d-8e54-f903-e8d015ca8cd2": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -1974,11 +3761,12 @@ "filterId": null, "dataKeys": [ { - "name": "jsExecutionCountHourly", + "name": "transportDataPointsCountHourly", "type": "timeseries", - "label": "{i18n:api-usage.javascript-executions}", - "color": "#ff9900", + "label": "{i18n:api-usage.transport-data-points}", + "color": "#4CAF50", "settings": { + "yAxisId": "default", "showInLegend": true, "dataHiddenByDefault": false, "type": "bar", @@ -2001,6 +3789,8 @@ "lineHeight": "1" }, "pointLabelColor": "rgba(0, 0, 0, 0.76)", + "enablePointLabelBackground": false, + "pointLabelBackground": "rgba(255,255,255,0.56)", "pointShape": "emptyCircle", "pointSize": 4, "fillAreaSettings": { @@ -2027,6 +3817,8 @@ "lineHeight": "1" }, "labelColor": "rgba(0, 0, 0, 0.76)", + "enableLabelBackground": false, + "labelBackground": "rgba(255,255,255,0.56)", "backgroundSettings": { "type": "none", "opacity": 0.4, @@ -2035,81 +3827,14 @@ "end": 0 } } - } - }, - "_hash": 0.0661644137210089, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": null, - "postFuncBody": null, - "aggregationType": null - }, - { - "name": "tbelExecutionCountHourly", - "type": "timeseries", - "label": "{i18n:api-usage.tbel-executions}", - "color": "#4caf50", - "settings": { - "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)", - "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)", - "backgroundSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } - } + "comparisonSettings": { + "showValuesForComparison": false, + "comparisonValuesLabel": "", + "color": "" } }, - "_hash": 0.6818645685001823, + "_hash": 0.12814821361119078, "aggregationType": null, "units": null, "decimals": null, @@ -2126,22 +3851,33 @@ } ], "timewindow": { - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false, "hideAggregation": false, "hideAggInterval": false, "hideTimezone": false, - "selectedTab": 0, + "selectedTab": 1, "realtime": { "realtimeType": 0, - "timewindowMs": 86400000, + "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", - "interval": 3600000 + "hideInterval": false, + "hideLastInterval": false, + "hideFixedInterval": false, + "hideQuickInterval": false }, "aggregation": { "type": "NONE", - "limit": 50000 + "limit": 25000 }, "timezone": null }, @@ -2215,6 +3951,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -2225,9 +3962,9 @@ "noAggregationBarWidthSettings": { "strategy": "group", "groupWidth": { - "relative": false, - "relativeWidth": 2, - "absoluteWidth": 3600000 + "relative": true, + "relativeWidth": 6, + "absoluteWidth": 1800000 }, "barWidth": { "relative": true, @@ -2253,7 +3990,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -2270,7 +4008,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -2302,31 +4042,83 @@ "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.scripts-hourly-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-details}", - "icon": "insert_chart", - "useShowWidgetActionFunction": null, - "showWidgetActionFunction": "return true;", - "type": "openDashboardState", - "targetDashboardStateId": "script_functions", - "setEntityId": false, - "stateEntityParamName": null, - "openRightLayout": false, - "openInSeparateDialog": false, - "openInPopover": false, - "id": "4687d3f6-8800-a3b6-26e5-0d33f3b828a9" - } - ] - }, + "actions": {}, "showTitleIcon": false, "titleIcon": "thermostat", "iconColor": "#1F6BDD", @@ -2365,14 +4157,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "ca996b66-ab7e-f977-152c-98e4ebf2a901" + "id": "966ffee7-ba0d-8e54-f903-e8d015ca8cd2" }, - "a3c2f1bb-7d3a-f11c-7b3d-28cd84fdfe34": { + "84fbe63a-bcb6-7bc1-8af0-46b3b1ee5adc": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -2386,11 +4178,12 @@ "filterId": null, "dataKeys": [ { - "name": "storageDataPointsCountHourly", + "name": "ruleEngineExecutionCountHourly", "type": "timeseries", - "label": "{i18n:api-usage.data-points-storage-days}", - "color": "#1039ee", + "label": "{i18n:api-usage.rule-engine-executions}", + "color": "#AB00FF", "settings": { + "yAxisId": "default", "showInLegend": true, "dataHiddenByDefault": false, "type": "bar", @@ -2413,6 +4206,8 @@ "lineHeight": "1" }, "pointLabelColor": "rgba(0, 0, 0, 0.76)", + "enablePointLabelBackground": false, + "pointLabelBackground": "rgba(255,255,255,0.56)", "pointShape": "emptyCircle", "pointSize": 4, "fillAreaSettings": { @@ -2439,6 +4234,8 @@ "lineHeight": "1" }, "labelColor": "rgba(0, 0, 0, 0.76)", + "enableLabelBackground": false, + "labelBackground": "rgba(255,255,255,0.56)", "backgroundSettings": { "type": "none", "opacity": 0.4, @@ -2447,15 +4244,20 @@ "end": 0 } } + }, + "comparisonSettings": { + "showValuesForComparison": false, + "comparisonValuesLabel": "", + "color": "" } }, - "_hash": 0.0661644137210089, + "_hash": 0.01948850513940492, + "aggregationType": null, "units": null, "decimals": null, "funcBody": null, "usePostProcessing": null, - "postFuncBody": null, - "aggregationType": null + "postFuncBody": null } ], "alarmFilterConfig": { @@ -2466,22 +4268,23 @@ } ], "timewindow": { - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false, "hideAggregation": false, "hideAggInterval": false, "hideTimezone": false, - "selectedTab": 0, - "realtime": { - "realtimeType": 0, - "timewindowMs": 86400000, - "quickInterval": "CURRENT_DAY", - "interval": 300000 + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 2592000000, + "interval": 86400000, + "fixedTimewindow": { + "startTimeMs": 1709729389667, + "endTimeMs": 1709815789667 + }, + "quickInterval": "CURRENT_DAY" }, "aggregation": { - "type": "NONE", - "limit": 50000 + "type": "SUM", + "limit": 25000 }, "timezone": null }, @@ -2555,6 +4358,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -2565,9 +4369,9 @@ "noAggregationBarWidthSettings": { "strategy": "group", "groupWidth": { - "relative": false, - "relativeWidth": 2, - "absoluteWidth": 3600000 + "relative": true, + "relativeWidth": 6, + "absoluteWidth": 1800000 }, "barWidth": { "relative": true, @@ -2593,7 +4397,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -2610,7 +4415,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -2642,9 +4449,78 @@ "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.telemetry-persistence-hourly-activity}", + "title": "{i18n:api-usage.rule-engine-daily-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -2652,14 +4528,21 @@ "actions": { "headerButton": [ { - "name": "{i18n:api-usage.view-details}", - "icon": "insert_chart", + "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": "telemetry_persistence", - "setEntityId": false, + "targetDashboardStateId": "rule_engine_statistics", + "setEntityId": true, "stateEntityParamName": null, "openRightLayout": false, - "id": "16707efb-e572-bd02-c219-55fc1b0f672a" + "openInSeparateDialog": false, + "openInPopover": false, + "id": "2592147a-3f62-987a-78c0-cdb775fb4233" } ] }, @@ -2701,14 +4584,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "a3c2f1bb-7d3a-f11c-7b3d-28cd84fdfe34" + "id": "84fbe63a-bcb6-7bc1-8af0-46b3b1ee5adc" }, - "5cebd4f1-ff6e-62f9-025c-8e7583c3d66a": { + "43a2b982-6c02-d9bd-71ee-34e8e6cf8893": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -2722,11 +4605,12 @@ "filterId": null, "dataKeys": [ { - "name": "createdAlarmsCountHourly", + "name": "ruleEngineExecutionCount", "type": "timeseries", - "label": "{i18n:api-usage.alarms-created}", - "color": "#d35a00", + "label": "{i18n:api-usage.rule-engine-executions}", + "color": "#AB00FF", "settings": { + "yAxisId": "default", "showInLegend": true, "dataHiddenByDefault": false, "type": "bar", @@ -2749,6 +4633,8 @@ "lineHeight": "1" }, "pointLabelColor": "rgba(0, 0, 0, 0.76)", + "enablePointLabelBackground": false, + "pointLabelBackground": "rgba(255,255,255,0.56)", "pointShape": "emptyCircle", "pointSize": 4, "fillAreaSettings": { @@ -2775,6 +4661,8 @@ "lineHeight": "1" }, "labelColor": "rgba(0, 0, 0, 0.76)", + "enableLabelBackground": false, + "labelBackground": "rgba(255,255,255,0.56)", "backgroundSettings": { "type": "none", "opacity": 0.4, @@ -2783,15 +4671,20 @@ "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": { @@ -2802,22 +4695,33 @@ } ], "timewindow": { - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false, "hideAggregation": false, "hideAggInterval": false, "hideTimezone": false, - "selectedTab": 0, + "selectedTab": 1, "realtime": { "realtimeType": 0, - "timewindowMs": 86400000, + "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", - "interval": 300000 + "hideInterval": false, + "hideLastInterval": false, + "hideFixedInterval": false, + "hideQuickInterval": false }, "aggregation": { "type": "NONE", - "limit": 50000 + "limit": 25000 }, "timezone": null }, @@ -2891,6 +4795,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -2901,9 +4806,9 @@ "noAggregationBarWidthSettings": { "strategy": "group", "groupWidth": { - "relative": false, - "relativeWidth": 2, - "absoluteWidth": 3600000 + "relative": true, + "relativeWidth": 6, + "absoluteWidth": 1800000 }, "barWidth": { "relative": true, @@ -2929,7 +4834,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -2946,7 +4852,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -2970,17 +4878,86 @@ "animationEasingUpdate": "cubicOut", "animationDelayUpdate": 0 }, - "background": { - "type": "color", - "color": "#fff", - "overlay": { - "enabled": false, - "color": "rgba(255,255,255,0.72)", - "blur": 3 - } - } + "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.alarms-created-hourly-activity}", + "title": "{i18n:api-usage.rule-engine-monthly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -2988,19 +4965,21 @@ "actions": { "headerButton": [ { - "name": "{i18n:api-usage.view-details}", - "icon": "insert_chart", + "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": "alarms_created", - "setEntityId": false, + "targetDashboardStateId": "rule_engine_statistics", + "setEntityId": true, "stateEntityParamName": null, - "openInSeparateDialog": null, - "dialogTitle": null, - "dialogHideDashboardToolbar": true, - "dialogWidth": null, - "dialogHeight": null, "openRightLayout": false, - "id": "371882f9-ea23-3abc-fca8-9449c5dfdd6b" + "openInSeparateDialog": false, + "openInPopover": false, + "id": "b6ba96cf-48b8-f40f-f010-10b95e7dc819" } ] }, @@ -3042,14 +5021,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "5cebd4f1-ff6e-62f9-025c-8e7583c3d66a" + "id": "43a2b982-6c02-d9bd-71ee-34e8e6cf8893" }, - "bc0c8840-a9b5-5583-de7b-9e9450f5d8fe": { + "76fe83c9-c30f-00a5-6299-40c759ca6705": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -3063,140 +5042,34 @@ "filterId": null, "dataKeys": [ { - "name": "emailCountHourly", + "name": "jsExecutionCountHourly", "type": "timeseries", - "label": "{i18n:api-usage.email-messages}", - "color": "#4caf50", + "label": "{i18n:api-usage.javascript-function-executions}", + "color": "#FF9900", "settings": { - "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)", - "pointShape": "emptyCircle", - "pointSize": 4, - "fillAreaSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } + "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 }, - "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)", - "backgroundSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } - } - } - }, - "_hash": 0.1348755140779876, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": null, - "postFuncBody": null, - "aggregationType": null - }, - { - "name": "smsCountHourly", - "type": "timeseries", - "label": "{i18n:api-usage.sms-messages}", - "color": "#f36021", - "settings": { - "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)", - "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)", - "backgroundSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } - } - } + "yAxisId": "default" }, "_hash": 0.0661644137210089, "units": null, @@ -3215,24 +5088,16 @@ } ], "timewindow": { - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false, - "hideAggregation": false, - "hideAggInterval": false, - "hideTimezone": false, "selectedTab": 0, "realtime": { "realtimeType": 0, - "timewindowMs": 86400000, - "quickInterval": "CURRENT_DAY", - "interval": 300000 + "interval": 3600000, + "timewindowMs": 86400000 }, "aggregation": { "type": "NONE", "limit": 50000 - }, - "timezone": null + } }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -3304,6 +5169,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -3314,9 +5180,9 @@ "noAggregationBarWidthSettings": { "strategy": "group", "groupWidth": { - "relative": false, - "relativeWidth": 2, - "absoluteWidth": 3600000 + "relative": true, + "relativeWidth": 6, + "absoluteWidth": 1800000 }, "barWidth": { "relative": true, @@ -3342,7 +5208,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -3359,7 +5226,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -3391,32 +5260,83 @@ "color": "rgba(255,255,255,0.72)", "blur": 3 } - } - }, - "title": "{i18n:api-usage.notifications-hourly-activity}", - "dropShadow": true, - "enableFullscreen": true, - "titleStyle": null, - "configMode": "basic", - "actions": { - "headerButton": [ - { - "name": "{i18n:api-usage.view-details}", - "icon": "insert_chart", - "type": "openDashboardState", - "targetDashboardStateId": "notifications", - "setEntityId": false, - "stateEntityParamName": null, - "openInSeparateDialog": null, - "dialogTitle": null, - "dialogHideDashboardToolbar": true, - "dialogWidth": null, - "dialogHeight": null, - "openRightLayout": false, - "id": "49aefac0-ec5e-d6f3-f39c-8744759f4b19" - } - ] + }, + "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.javascript-function-executions-hourly-activity}", + "dropShadow": true, + "enableFullscreen": true, + "titleStyle": null, + "configMode": "basic", + "actions": {}, "showTitleIcon": false, "titleIcon": "thermostat", "iconColor": "#1F6BDD", @@ -3455,14 +5375,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "bc0c8840-a9b5-5583-de7b-9e9450f5d8fe" + "id": "76fe83c9-c30f-00a5-6299-40c759ca6705" }, - "0b091dc3-eec3-847e-d0ad-fdf12d474e7a": { + "a43598d1-7bfd-f329-ee61-c343f34f069f": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -3476,10 +5396,10 @@ "filterId": null, "dataKeys": [ { - "name": "transportMsgCountHourly", + "name": "jsExecutionCountHourly", "type": "timeseries", - "label": "{i18n:api-usage.transport-messages}", - "color": "#2196f3", + "label": "{i18n:api-usage.javascript-function-executions}", + "color": "#FF9900", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -3502,58 +5422,26 @@ "comparisonSettings": { "showValuesForComparison": true }, - "type": "bar" + "type": "bar", + "yAxisId": "default" }, "_hash": 0.0661644137210089, "units": null, "decimals": null, "funcBody": null, "usePostProcessing": null, - "postFuncBody": null - }, - { - "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" - }, - "_hash": 0.46849996721308895, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": null, - "postFuncBody": null + "postFuncBody": null, + "aggregationType": null } - ] + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } } ], "timewindow": { - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false, "hideAggregation": false, "hideAggInterval": false, "hideTimezone": false, @@ -3644,6 +5532,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -3654,8 +5543,8 @@ "noAggregationBarWidthSettings": { "strategy": "group", "groupWidth": { - "relative": false, - "relativeWidth": 2, + "relative": true, + "relativeWidth": 6, "absoluteWidth": 1800000 }, "barWidth": { @@ -3682,7 +5571,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -3699,7 +5589,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -3731,9 +5623,78 @@ "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-daily-activity}", + "title": "{i18n:api-usage.javascript-function-executions-daily-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -3777,14 +5738,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "0b091dc3-eec3-847e-d0ad-fdf12d474e7a" + "id": "a43598d1-7bfd-f329-ee61-c343f34f069f" }, - "536d7104-49f8-fde6-5827-61b8419f15ec": { + "3ebd62a8-dcb7-c96b-8571-e61084248f5b": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -3798,10 +5759,10 @@ "filterId": null, "dataKeys": [ { - "name": "transportMsgCount", + "name": "jsExecutionCount", "type": "timeseries", - "label": "{i18n:api-usage.transport-messages}", - "color": "#2196f3", + "label": "{i18n:api-usage.javascript-function-executions}", + "color": "#FF9900", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -3824,7 +5785,8 @@ "comparisonSettings": { "showValuesForComparison": true }, - "type": "bar" + "type": "bar", + "yAxisId": "default" }, "_hash": 0.0661644137210089, "units": null, @@ -3833,73 +5795,38 @@ "usePostProcessing": null, "postFuncBody": null, "aggregationType": null - }, - { - "name": "transportDataPointsCount", - "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" - }, - "_hash": 0.46849996721308895, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": null, - "postFuncBody": null, - "aggregationType": null } - ], - "alarmFilterConfig": { - "statusList": [ - "ACTIVE" - ] - } + ] } ], "timewindow": { - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false, "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, - "interval": 86400000, - "fixedTimewindow": { - "startTimeMs": 1709729389667, - "endTimeMs": 1709815789667 - }, - "quickInterval": "CURRENT_DAY" + "fixedTimewindow": null, + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideFixedInterval": false, + "hideQuickInterval": false }, "aggregation": { "type": "NONE", - "limit": 1000 + "limit": 25000 }, "timezone": null }, @@ -3973,6 +5900,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -3985,7 +5913,7 @@ "groupWidth": { "relative": true, "relativeWidth": 6, - "absoluteWidth": 900000000 + "absoluteWidth": 1800000 }, "barWidth": { "relative": true, @@ -4011,58 +5939,130 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "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" }, - "showTooltip": true, - "tooltipTrigger": "axis", - "tooltipValueFont": { + "legendColumnTitleFont": { "family": "Roboto", "size": 12, "sizeUnit": "px", "style": "normal", - "weight": "500", + "weight": "400", "lineHeight": "16px" }, - "tooltipValueColor": "rgba(0, 0, 0, 0.76)", - "tooltipShowDate": true, - "tooltipDateFormat": { - "format": "yyyy-MM-dd HH:mm:ss", - "lastUpdateAgo": false, - "custom": false + "legendColumnTitleColor": "rgba(0, 0, 0, 0.38)", + "legendValueFont": { + "family": "Roboto", + "size": 12, + "sizeUnit": "px", + "style": "normal", + "weight": "500", + "lineHeight": "16px" }, - "tooltipDateFont": { + "legendValueColor": "rgba(0, 0, 0, 0.87)", + "tooltipLabelFont": { "family": "Roboto", - "size": 11, + "size": 12, "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 - } - } + "tooltipLabelColor": "rgba(0, 0, 0, 0.76)", + "tooltipHideZeroValues": null, + "padding": "12px" }, - "title": "{i18n:api-usage.transport-daily-activity}", + "title": "{i18n:api-usage.javascript-function-executions-monthly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -4106,14 +6106,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "536d7104-49f8-fde6-5827-61b8419f15ec" + "id": "3ebd62a8-dcb7-c96b-8571-e61084248f5b" }, - "c77e417c-ad9d-8e23-3ea1-c75edd653bc0": { + "88e25971-e5cb-eebb-3c7c-1ce33a8a38f4": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -4127,10 +6127,10 @@ "filterId": null, "dataKeys": [ { - "name": "ruleEngineExecutionCountHourly", + "name": "tbelExecutionCountHourly", "type": "timeseries", - "label": "{i18n:api-usage.rule-engine-executions}", - "color": "#ab00ff", + "label": "{i18n:api-usage.tbel-function-executions}", + "color": "#4CAF50", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -4153,41 +6153,36 @@ "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": { - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false, - "hideAggregation": false, - "hideAggInterval": false, - "hideTimezone": false, - "selectedTab": 1, - "history": { - "historyType": 0, - "timewindowMs": 2592000000, - "interval": 86400000, - "fixedTimewindow": { - "startTimeMs": 1709729900300, - "endTimeMs": 1709816300300 - }, - "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", @@ -4259,6 +6254,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -4269,8 +6265,8 @@ "noAggregationBarWidthSettings": { "strategy": "group", "groupWidth": { - "relative": false, - "relativeWidth": 2, + "relative": true, + "relativeWidth": 6, "absoluteWidth": 1800000 }, "barWidth": { @@ -4297,7 +6293,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -4314,7 +6311,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -4346,9 +6345,78 @@ "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-daily-activity}", + "title": "{i18n:api-usage.tbel-function-executions-hourly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -4392,14 +6460,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "c77e417c-ad9d-8e23-3ea1-c75edd653bc0" + "id": "88e25971-e5cb-eebb-3c7c-1ce33a8a38f4" }, - "870904d2-d2e1-a1b9-ce56-b03fd47259b5": { + "a1b5731c-e3b3-8cfb-7c50-3abcdce891d2": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -4413,10 +6481,10 @@ "filterId": null, "dataKeys": [ { - "name": "ruleEngineExecutionCount", + "name": "tbelExecutionCountHourly", "type": "timeseries", - "label": "{i18n:api-usage.rule-engine-executions}", - "color": "#ab00ff", + "label": "{i18n:api-usage.tbel-function-executions}", + "color": "#4CAF50", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -4439,32 +6507,45 @@ "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": { - "hideInterval": false, "hideAggregation": false, "hideAggInterval": false, + "hideTimezone": false, "selectedTab": 1, "history": { "historyType": 0, - "timewindowMs": 31536000000, - "interval": 1000 + "timewindowMs": 2592000000, + "interval": 86400000, + "fixedTimewindow": { + "startTimeMs": 1709729389667, + "endTimeMs": 1709815789667 + }, + "quickInterval": "CURRENT_DAY" }, "aggregation": { - "type": "NONE", - "limit": 1000 - } + "type": "SUM", + "limit": 25000 + }, + "timezone": null }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -4536,6 +6617,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -4548,7 +6630,7 @@ "groupWidth": { "relative": true, "relativeWidth": 6, - "absoluteWidth": 900000000 + "absoluteWidth": 1800000 }, "barWidth": { "relative": true, @@ -4574,7 +6656,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -4591,7 +6674,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -4623,9 +6708,78 @@ "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-monthly-activity}", + "title": "{i18n:api-usage.tbel-function-executions-daily-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -4669,14 +6823,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "870904d2-d2e1-a1b9-ce56-b03fd47259b5" + "id": "a1b5731c-e3b3-8cfb-7c50-3abcdce891d2" }, - "c66e5060-57fd-11e7-6616-65b82c294ac2": { + "efc8d4e9-dee2-b677-c378-c1a666543bf4": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -4690,10 +6844,10 @@ "filterId": null, "dataKeys": [ { - "name": "jsExecutionCountHourly", + "name": "tbelExecutionCount", "type": "timeseries", - "label": "{i18n:api-usage.javascript-executions}", - "color": "#ff9900", + "label": "{i18n:api-usage.tbel-function-executions}", + "color": "#4CAF50", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -4716,48 +6870,50 @@ "comparisonSettings": { "showValuesForComparison": true }, - "type": "bar" + "type": "bar", + "yAxisId": "default" }, "_hash": 0.0661644137210089, "units": null, "decimals": null, "funcBody": null, "usePostProcessing": null, - "postFuncBody": null - }, - { - "name": "tbelExecutionCountHourly", - "type": "timeseries", - "label": "{i18n:api-usage.tbel-executions}", - "color": "#4caf50", - "settings": { - "type": "bar" - }, - "_hash": 0.5212969314724616, - "aggregationType": null, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": null, - "postFuncBody": null + "postFuncBody": null, + "aggregationType": null } ] } ], "timewindow": { - "hideInterval": false, "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, - "timewindowMs": 2592000000, - "interval": 86400000 + "interval": 2592000000, + "timewindowMs": 31536000000, + "fixedTimewindow": null, + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideFixedInterval": false, + "hideQuickInterval": false }, "aggregation": { - "type": "SUM", - "limit": 1000 - } + "type": "NONE", + "limit": 25000 + }, + "timezone": null }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -4829,6 +6985,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -4839,8 +6996,8 @@ "noAggregationBarWidthSettings": { "strategy": "group", "groupWidth": { - "relative": false, - "relativeWidth": 2, + "relative": true, + "relativeWidth": 6, "absoluteWidth": 1800000 }, "barWidth": { @@ -4867,7 +7024,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -4884,7 +7042,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -4916,9 +7076,78 @@ "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.scripts-daily-activity}", + "title": "{i18n:api-usage.tbel-function-executions-monthly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -4962,14 +7191,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "c66e5060-57fd-11e7-6616-65b82c294ac2" + "id": "efc8d4e9-dee2-b677-c378-c1a666543bf4" }, - "d0e8603e-5d2e-9287-e2c6-8ccbe9c66806": { + "1249d3e2-6b3a-4e4a-65e9-6ed22959871e": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -4983,10 +7212,10 @@ "filterId": null, "dataKeys": [ { - "name": "jsExecutionCount", + "name": "storageDataPointsCountHourly", "type": "timeseries", - "label": "{i18n:api-usage.javascript-executions}", - "color": "#ff9900", + "label": "{i18n:api-usage.data-points-storage-days}", + "color": "#1039EE", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -5009,48 +7238,45 @@ "comparisonSettings": { "showValuesForComparison": true }, - "type": "bar" + "type": "bar", + "yAxisId": "default" }, "_hash": 0.0661644137210089, "units": null, "decimals": null, "funcBody": null, "usePostProcessing": null, - "postFuncBody": null - }, - { - "name": "tbelExecutionCount", - "type": "timeseries", - "label": "{i18n:api-usage.tbel-executions}", - "color": "#4caf50", - "settings": { - "type": "bar" - }, - "_hash": 0.49748239768082403, - "aggregationType": null, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": null, - "postFuncBody": null + "postFuncBody": null, + "aggregationType": null } - ] + ], + "alarmFilterConfig": { + "statusList": [ + "ACTIVE" + ] + } } ], "timewindow": { - "hideInterval": false, "hideAggregation": false, "hideAggInterval": false, + "hideTimezone": false, "selectedTab": 1, "history": { "historyType": 0, - "timewindowMs": 31536000000, - "interval": 1000 + "timewindowMs": 2592000000, + "interval": 86400000, + "fixedTimewindow": { + "startTimeMs": 1709729389667, + "endTimeMs": 1709815789667 + }, + "quickInterval": "CURRENT_DAY" }, "aggregation": { - "type": "NONE", - "limit": 1000 - } + "type": "SUM", + "limit": 25000 + }, + "timezone": null }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -5122,6 +7348,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -5134,12 +7361,12 @@ "groupWidth": { "relative": true, "relativeWidth": 6, - "absoluteWidth": 900000000 + "absoluteWidth": 1800000 }, "barWidth": { - "relative": false, + "relative": true, "relativeWidth": 2, - "absoluteWidth": 900000000 + "absoluteWidth": 1000 } }, "showLegend": true, @@ -5160,7 +7387,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -5177,7 +7405,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -5209,9 +7439,78 @@ "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.scripts-monthly-activity}", + "title": "{i18n:api-usage.data-points-storage-days-daily-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -5255,14 +7554,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "d0e8603e-5d2e-9287-e2c6-8ccbe9c66806" + "id": "1249d3e2-6b3a-4e4a-65e9-6ed22959871e" }, - "7f4100d2-41be-4954-d353-1d45000dbbbb": { + "c2f2da29-741d-54f6-5f1d-6f6ae616ea02": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -5276,10 +7575,10 @@ "filterId": null, "dataKeys": [ { - "name": "storageDataPointsCountHourly", + "name": "storageDataPointsCount", "type": "timeseries", "label": "{i18n:api-usage.data-points-storage-days}", - "color": "#1039ee", + "color": "#1039EE", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -5302,32 +7601,55 @@ "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": { - "hideInterval": false, "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, - "timewindowMs": 2592000000, - "interval": 86400000 + "interval": 2592000000, + "timewindowMs": 31536000000, + "fixedTimewindow": null, + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideFixedInterval": false, + "hideQuickInterval": false }, "aggregation": { - "type": "SUM", - "limit": 1000 - } + "type": "NONE", + "limit": 25000 + }, + "timezone": null }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -5399,6 +7721,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -5409,8 +7732,8 @@ "noAggregationBarWidthSettings": { "strategy": "group", "groupWidth": { - "relative": false, - "relativeWidth": 2, + "relative": true, + "relativeWidth": 6, "absoluteWidth": 1800000 }, "barWidth": { @@ -5437,7 +7760,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -5454,7 +7778,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -5486,9 +7812,78 @@ "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.telemetry-persistence-daily-activity}", + "title": "{i18n:api-usage.data-points-storage-days-monthly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -5532,14 +7927,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "7f4100d2-41be-4954-d353-1d45000dbbbb" + "id": "c2f2da29-741d-54f6-5f1d-6f6ae616ea02" }, - "226ef8c9-8488-3664-21ac-0b6217336202": { + "8e07dbe5-aa7a-19c1-c470-5f055df948a7": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -5553,10 +7948,10 @@ "filterId": null, "dataKeys": [ { - "name": "storageDataPointsCount", + "name": "createdAlarmsCountHourly", "type": "timeseries", - "label": "{i18n:api-usage.data-points-storage-days}", - "color": "#1039ee", + "label": "{i18n:api-usage.alarms-created}", + "color": "#D35A00", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -5579,31 +7974,35 @@ "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": { - "hideInterval": false, - "hideAggregation": false, - "hideAggInterval": false, - "selectedTab": 1, - "history": { - "historyType": 0, - "timewindowMs": 31536000000, - "interval": 1000 + "selectedTab": 0, + "realtime": { + "realtimeType": 0, + "interval": 3600000, + "timewindowMs": 86400000 }, "aggregation": { "type": "NONE", - "limit": 1000 + "limit": 50000 } }, "showTitle": true, @@ -5676,6 +8075,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -5688,7 +8088,7 @@ "groupWidth": { "relative": true, "relativeWidth": 6, - "absoluteWidth": 900000000 + "absoluteWidth": 1800000 }, "barWidth": { "relative": true, @@ -5714,7 +8114,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -5731,7 +8132,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -5763,9 +8166,78 @@ "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.telemetry-persistence-monthly-activity}", + "title": "{i18n:api-usage.alarms-created-hourly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -5809,14 +8281,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "226ef8c9-8488-3664-21ac-0b6217336202" + "id": "8e07dbe5-aa7a-19c1-c470-5f055df948a7" }, - "bef6c27b-9fe7-ee92-40d9-9696c501a1f9": { + "e0fe9887-d61c-7813-05a7-f60811e5c5bf": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -5833,7 +8305,7 @@ "name": "createdAlarmsCountHourly", "type": "timeseries", "label": "{i18n:api-usage.alarms-created}", - "color": "#d35a00", + "color": "#D35A00", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -5843,7 +8315,7 @@ "fillLines": false, "showPoints": false, "showPointShape": "circle", - "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "pointShapeFormatter": "", "showPointsLineWidth": 5, "showPointsRadius": 3, "showSeparateAxis": false, @@ -5856,32 +8328,45 @@ "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": { - "hideInterval": false, "hideAggregation": false, "hideAggInterval": false, + "hideTimezone": false, "selectedTab": 1, "history": { "historyType": 0, "timewindowMs": 2592000000, - "interval": 86400000 + "interval": 86400000, + "fixedTimewindow": { + "startTimeMs": 1709729389667, + "endTimeMs": 1709815789667 + }, + "quickInterval": "CURRENT_DAY" }, "aggregation": { "type": "SUM", - "limit": 1000 - } + "limit": 25000 + }, + "timezone": null }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -5953,6 +8438,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -5963,8 +8449,8 @@ "noAggregationBarWidthSettings": { "strategy": "group", "groupWidth": { - "relative": false, - "relativeWidth": 2, + "relative": true, + "relativeWidth": 6, "absoluteWidth": 1800000 }, "barWidth": { @@ -5991,7 +8477,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -6008,7 +8495,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -6040,7 +8529,76 @@ "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.alarms-created-daily-activity}", "dropShadow": true, @@ -6086,14 +8644,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "bef6c27b-9fe7-ee92-40d9-9696c501a1f9" + "id": "e0fe9887-d61c-7813-05a7-f60811e5c5bf" }, - "52305cf8-2258-5745-a0e7-41a171594bb3": { + "99a40c35-c232-16c5-c42f-3cc80ddb9243": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -6110,7 +8668,7 @@ "name": "createdAlarmsCount", "type": "timeseries", "label": "{i18n:api-usage.alarms-created}", - "color": "#d35a00", + "color": "#D35A00", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -6120,7 +8678,7 @@ "fillLines": false, "showPoints": false, "showPointShape": "circle", - "pointShapeFormatter": "var size = radius * Math.sqrt(Math.PI) / 2;\nctx.moveTo(x - size, y - size);\nctx.lineTo(x + size, y + size);\nctx.moveTo(x - size, y + size);\nctx.lineTo(x + size, y - size);", + "pointShapeFormatter": "", "showPointsLineWidth": 5, "showPointsRadius": 3, "showSeparateAxis": false, @@ -6133,32 +8691,55 @@ "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": { - "hideInterval": false, "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, - "interval": 1000 + "fixedTimewindow": null, + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideFixedInterval": false, + "hideQuickInterval": false }, "aggregation": { "type": "NONE", - "limit": 1000 - } + "limit": 25000 + }, + "timezone": null }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -6230,6 +8811,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -6242,7 +8824,7 @@ "groupWidth": { "relative": true, "relativeWidth": 6, - "absoluteWidth": 900000000 + "absoluteWidth": 1800000 }, "barWidth": { "relative": true, @@ -6268,7 +8850,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -6285,7 +8868,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -6317,7 +8902,76 @@ "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.alarms-created-monthly-activity}", "dropShadow": true, @@ -6363,14 +9017,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "52305cf8-2258-5745-a0e7-41a171594bb3" + "id": "99a40c35-c232-16c5-c42f-3cc80ddb9243" }, - "36fdf999-ca22-9a4c-269d-3f004d792792": { + "407f7630-406e-9c24-cb3d-b1cbdd190f15": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -6387,7 +9041,7 @@ "name": "emailCountHourly", "type": "timeseries", "label": "{i18n:api-usage.email-messages}", - "color": "#d35a00", + "color": "#F36021", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -6410,31 +9064,35 @@ "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": { - "hideInterval": false, - "hideAggregation": false, - "hideAggInterval": false, - "selectedTab": 1, - "history": { - "historyType": 0, - "timewindowMs": 2592000000, - "interval": 86400000 + "selectedTab": 0, + "realtime": { + "realtimeType": 0, + "interval": 3600000, + "timewindowMs": 86400000 }, "aggregation": { - "type": "SUM", - "limit": 1000 + "type": "NONE", + "limit": 50000 } }, "showTitle": true, @@ -6507,6 +9165,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -6517,8 +9176,8 @@ "noAggregationBarWidthSettings": { "strategy": "group", "groupWidth": { - "relative": false, - "relativeWidth": 2, + "relative": true, + "relativeWidth": 6, "absoluteWidth": 1800000 }, "barWidth": { @@ -6545,7 +9204,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -6562,7 +9222,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -6594,9 +9256,78 @@ "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.email-messages-daily-activity}", + "title": "{i18n:api-usage.emails-hourly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -6640,14 +9371,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "36fdf999-ca22-9a4c-269d-3f004d792792" + "id": "407f7630-406e-9c24-cb3d-b1cbdd190f15" }, - "9a191755-499d-535e-86c5-061102729c02": { + "b12fb875-89fe-af4c-b344-bf4178de419f": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -6661,10 +9392,10 @@ "filterId": null, "dataKeys": [ { - "name": "smsCountHourly", + "name": "emailCountHourly", "type": "timeseries", - "label": "{i18n:api-usage.sms-messages}", - "color": "#f36021", + "label": "{i18n:api-usage.email-messages}", + "color": "#F36021", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -6687,32 +9418,45 @@ "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": { - "hideInterval": false, "hideAggregation": false, "hideAggInterval": false, + "hideTimezone": false, "selectedTab": 1, "history": { "historyType": 0, "timewindowMs": 2592000000, - "interval": 86400000 + "interval": 86400000, + "fixedTimewindow": { + "startTimeMs": 1709729389667, + "endTimeMs": 1709815789667 + }, + "quickInterval": "CURRENT_DAY" }, "aggregation": { "type": "SUM", - "limit": 1000 - } + "limit": 25000 + }, + "timezone": null }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -6784,6 +9528,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -6794,8 +9539,8 @@ "noAggregationBarWidthSettings": { "strategy": "group", "groupWidth": { - "relative": false, - "relativeWidth": 2, + "relative": true, + "relativeWidth": 6, "absoluteWidth": 1800000 }, "barWidth": { @@ -6822,7 +9567,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -6839,7 +9585,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -6871,9 +9619,78 @@ "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.sms-messages-daily-activity}", + "title": "{i18n:api-usage.emails-daily-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -6917,14 +9734,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "9a191755-499d-535e-86c5-061102729c02" + "id": "b12fb875-89fe-af4c-b344-bf4178de419f" }, - "4b266318-8357-33ef-ca5a-74cbf90e014f": { + "0b00099d-d131-3e8b-97ce-c4b8d7bcab1f": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -6941,7 +9758,7 @@ "name": "emailCount", "type": "timeseries", "label": "{i18n:api-usage.email-messages}", - "color": "#d35a00", + "color": "#F36021", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -6964,32 +9781,50 @@ "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 } ] } ], "timewindow": { - "hideInterval": false, "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, - "interval": 1000 + "fixedTimewindow": null, + "quickInterval": "CURRENT_DAY", + "hideInterval": false, + "hideLastInterval": false, + "hideFixedInterval": false, + "hideQuickInterval": false }, "aggregation": { "type": "NONE", - "limit": 1000 - } + "limit": 25000 + }, + "timezone": null }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -7061,6 +9896,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -7073,7 +9909,7 @@ "groupWidth": { "relative": true, "relativeWidth": 6, - "absoluteWidth": 900000000 + "absoluteWidth": 1800000 }, "barWidth": { "relative": true, @@ -7099,7 +9935,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -7116,7 +9953,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -7148,9 +9987,78 @@ "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.email-messages-monthly-activity}", + "title": "{i18n:api-usage.emails-monthly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -7194,14 +10102,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "4b266318-8357-33ef-ca5a-74cbf90e014f" + "id": "0b00099d-d131-3e8b-97ce-c4b8d7bcab1f" }, - "5aa33b0b-3bd5-7fe7-ee72-f564c2ca79d8": { + "5648a56e-5a33-3018-92bd-d8e3dbe8aeee": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -7215,10 +10123,10 @@ "filterId": null, "dataKeys": [ { - "name": "smsCount", + "name": "smsCountHourly", "type": "timeseries", "label": "{i18n:api-usage.sms-messages}", - "color": "#f36021", + "color": "#F36021", "settings": { "excludeFromStacking": false, "hideDataByDefault": false, @@ -7241,31 +10149,35 @@ "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": { - "hideInterval": false, - "hideAggregation": false, - "hideAggInterval": false, - "selectedTab": 1, - "history": { - "historyType": 0, - "timewindowMs": 31536000000, - "interval": 1000 + "selectedTab": 0, + "realtime": { + "realtimeType": 0, + "interval": 3600000, + "timewindowMs": 86400000 }, "aggregation": { "type": "NONE", - "limit": 1000 + "limit": 50000 } }, "showTitle": true, @@ -7338,6 +10250,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -7350,7 +10263,7 @@ "groupWidth": { "relative": true, "relativeWidth": 6, - "absoluteWidth": 900000000 + "absoluteWidth": 1800000 }, "barWidth": { "relative": true, @@ -7376,7 +10289,8 @@ "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -7393,7 +10307,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -7425,9 +10341,78 @@ "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.sms-messages-monthly-activity}", + "title": "{i18n:api-usage.sms-messages-hourly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -7471,14 +10456,14 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "5aa33b0b-3bd5-7fe7-ee72-f564c2ca79d8" + "id": "5648a56e-5a33-3018-92bd-d8e3dbe8aeee" }, - "fa938580-33db-f1b3-fafc-bc3e3784ad57": { + "ab5518c1-34d6-7e17-04b4-6520496d5fe1": { "typeFullFqn": "system.time_series_chart", "type": "timeseries", "sizeX": 8, @@ -7487,266 +10472,76 @@ "datasources": [ { "type": "entity", - "entityAliasId": "2e4c97b0-257a-a1b9-690c-141d9bf2ec6f", + "name": null, + "entityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "filterId": null, "dataKeys": [ { - "name": "successfulMsgs", - "type": "timeseries", - "label": "{i18n:api-usage.successful}", - "color": "#4caf50", - "settings": { - "yAxisId": "default", - "showInLegend": true, - "dataHiddenByDefault": false, - "type": "line", - "lineSettings": { - "showLine": true, - "step": false, - "stepType": "start", - "smooth": false, - "lineType": "solid", - "lineWidth": 2.5, - "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)", - "pointShape": "circle", - "pointSize": 12, - "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)", - "backgroundSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } - } - } - }, - "_hash": 0.15490750967648736, - "aggregationType": null, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": null, - "postFuncBody": null - }, - { - "name": "failedMsgs", - "type": "timeseries", - "label": "{i18n:api-usage.permanent-failures}", - "color": "#ef5350", - "settings": { - "yAxisId": "default", - "showInLegend": true, - "dataHiddenByDefault": false, - "type": "line", - "lineSettings": { - "showLine": true, - "step": false, - "stepType": "start", - "smooth": false, - "lineType": "solid", - "lineWidth": 2.5, - "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)", - "pointShape": "circle", - "pointSize": 12, - "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)", - "backgroundSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } - } - } - }, - "_hash": 0.4186621166514697, - "aggregationType": null, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": null, - "postFuncBody": null - }, - { - "name": "tmpFailed", + "name": "smsCountHourly", "type": "timeseries", - "label": "{i18n:api-usage.processing-failures}", - "color": "#ffc107", + "label": "{i18n:api-usage.sms-messages}", + "color": "#F36021", "settings": { - "yAxisId": "default", - "showInLegend": true, - "dataHiddenByDefault": false, - "type": "line", - "lineSettings": { - "showLine": true, - "step": false, - "stepType": "start", - "smooth": false, - "lineType": "solid", - "lineWidth": 2.5, - "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)", - "pointShape": "circle", - "pointSize": 12, - "fillAreaSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } + "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 }, - "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)", - "backgroundSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } - } - } + "type": "bar", + "yAxisId": "default" }, - "_hash": 0.49891007198715376, - "aggregationType": null, + "_hash": 0.0661644137210089, "units": null, "decimals": null, "funcBody": null, "usePostProcessing": null, - "postFuncBody": null + "postFuncBody": null, + "aggregationType": null } ], "alarmFilterConfig": { "statusList": [ "ACTIVE" ] - }, - "latestDataKeys": [ - { - "name": "queueName", - "type": "entityField", - "label": "Queue name", - "color": "#ffc107", - "settings": {}, - "_hash": 0.7021721434431745 - }, - { - "name": "serviceId", - "type": "entityField", - "label": "Service Id", - "color": "#607d8b", - "settings": {}, - "_hash": 0.5924381120750077 - } - ] + } } ], "timewindow": { - "hideInterval": false, "hideAggregation": false, "hideAggInterval": false, - "selectedTab": 0, - "realtime": { - "timewindowMs": 3600000, - "interval": 1000 + "hideTimezone": false, + "selectedTab": 1, + "history": { + "historyType": 0, + "timewindowMs": 2592000000, + "interval": 86400000, + "fixedTimewindow": { + "startTimeMs": 1709729389667, + "endTimeMs": 1709815789667 + }, + "quickInterval": "CURRENT_DAY" }, "aggregation": { - "type": "NONE", - "limit": 10000 - } + "type": "SUM", + "limit": 25000 + }, + "timezone": null }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -7818,6 +10613,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -7828,8 +10624,8 @@ "noAggregationBarWidthSettings": { "strategy": "group", "groupWidth": { - "relative": false, - "relativeWidth": 2, + "relative": true, + "relativeWidth": 6, "absoluteWidth": 1800000 }, "barWidth": { @@ -7852,11 +10648,12 @@ "direction": "column", "position": "bottom", "sortDataKeys": false, - "showMin": true, - "showMax": true, + "showMin": false, + "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "showLatest": false, + "valueFormat": null }, "showTooltip": true, "tooltipTrigger": "axis", @@ -7873,7 +10670,9 @@ "tooltipDateFormat": { "format": "yyyy-MM-dd HH:mm:ss", "lastUpdateAgo": false, - "custom": false + "custom": false, + "auto": true, + "autoDateFormatSettings": {} }, "tooltipDateFont": { "family": "Roboto", @@ -7885,13 +10684,12 @@ }, "tooltipDateColor": "rgba(0, 0, 0, 0.76)", "tooltipDateInterval": true, - "tooltipHideZeroValues": true, "tooltipBackgroundColor": "rgba(255, 255, 255, 0.76)", "tooltipBackgroundBlur": 4, "animation": { "animation": true, "animationThreshold": 2000, - "animationDuration": 300, + "animationDuration": 1000, "animationEasing": "cubicOut", "animationDelay": 0, "animationDurationUpdate": 300, @@ -7907,9 +10705,77 @@ "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.queue-stats}", + "title": "{i18n:api-usage.sms-messages-daily-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -7932,230 +10798,118 @@ "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": "0px", - "iconSize": "0px" - }, - "row": 0, - "col": 0, - "id": "fa938580-33db-f1b3-fafc-bc3e3784ad57" - }, - "2ee89893-4e38-5331-95b7-3fd4f310c5a7": { - "typeFullFqn": "system.time_series_chart", - "type": "timeseries", - "sizeX": 8, - "sizeY": 5, - "config": { - "datasources": [ - { - "type": "entity", - "entityAliasId": "2e4c97b0-257a-a1b9-690c-141d9bf2ec6f", - "dataKeys": [ - { - "name": "timeoutMsgs", - "type": "timeseries", - "label": "{i18n:api-usage.permanent-timeouts}", - "color": "#4caf50", - "settings": { - "yAxisId": "default", - "showInLegend": true, - "dataHiddenByDefault": false, - "type": "line", - "lineSettings": { - "showLine": true, - "step": false, - "stepType": "start", - "smooth": false, - "lineType": "solid", - "lineWidth": 2.5, - "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)", - "pointShape": "circle", - "pointSize": 12, - "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)", - "backgroundSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } - } - } - }, - "_hash": 0.565222981550328, - "aggregationType": null, - "units": null, - "decimals": null, - "funcBody": null, - "usePostProcessing": null, - "postFuncBody": null - }, + "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": "ab5518c1-34d6-7e17-04b4-6520496d5fe1" + }, + "2e7326ac-98d3-e68c-b7cf-948118a3f140": { + "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": "tmpTimeout", + "name": "smsCount", "type": "timeseries", - "label": "{i18n:api-usage.processing-timeouts}", - "color": "#9c27b0", + "label": "{i18n:api-usage.sms-messages}", + "color": "#F36021", "settings": { - "yAxisId": "default", - "showInLegend": true, - "dataHiddenByDefault": false, - "type": "line", - "lineSettings": { - "showLine": true, - "step": false, - "stepType": "start", - "smooth": false, - "lineType": "solid", - "lineWidth": 2.5, - "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)", - "pointShape": "circle", - "pointSize": 12, - "fillAreaSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } + "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 }, - "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)", - "backgroundSettings": { - "type": "none", - "opacity": 0.4, - "gradient": { - "start": 100, - "end": 0 - } - } - } + "type": "bar", + "yAxisId": "default" }, - "_hash": 0.2679547062508352, - "aggregationType": null, + "_hash": 0.0661644137210089, "units": null, "decimals": null, "funcBody": null, "usePostProcessing": null, - "postFuncBody": null - } - ], - "alarmFilterConfig": { - "statusList": [ - "ACTIVE" - ] - }, - "latestDataKeys": [ - { - "name": "queueName", - "type": "entityField", - "label": "Queue name", - "color": "#f44336", - "settings": {}, - "_hash": 0.7066844328378095 - }, - { - "name": "serviceId", - "type": "entityField", - "label": "Service Id", - "color": "#ffc107", - "settings": {}, - "_hash": 0.1371570237026627 + "postFuncBody": null, + "aggregationType": null } ] } ], "timewindow": { - "hideInterval": false, "hideAggregation": false, "hideAggInterval": false, - "selectedTab": 0, + "hideTimezone": false, + "selectedTab": 1, "realtime": { - "timewindowMs": 3600000, - "interval": 1000 + "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": 10000 - } + "limit": 25000 + }, + "timezone": null }, "showTitle": true, "backgroundColor": "#FFFFFF", @@ -8227,6 +10981,7 @@ "lineHeight": "1" }, "tickLabelColor": "rgba(0, 0, 0, 0.54)", + "ticksFormat": {}, "showTicks": true, "ticksColor": "rgba(0, 0, 0, 0.54)", "showLine": true, @@ -8237,8 +10992,8 @@ "noAggregationBarWidthSettings": { "strategy": "group", "groupWidth": { - "relative": false, - "relativeWidth": 2, + "relative": true, + "relativeWidth": 6, "absoluteWidth": 1800000 }, "barWidth": { @@ -8261,15 +11016,113 @@ "direction": "column", "position": "bottom", "sortDataKeys": false, - "showMin": true, - "showMax": true, + "showMin": false, + "showMax": false, "showAvg": false, "showTotal": true, - "showLatest": false + "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" }, - "showTooltip": true, - "tooltipTrigger": "axis", - "tooltipValueFont": { + "legendColumnTitleColor": "rgba(0, 0, 0, 0.38)", + "legendValueFont": { "family": "Roboto", "size": 12, "sizeUnit": "px", @@ -8277,48 +11130,20 @@ "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 - }, - "tooltipDateFont": { + "legendValueColor": "rgba(0, 0, 0, 0.87)", + "tooltipLabelFont": { "family": "Roboto", - "size": 11, + "size": 12, "sizeUnit": "px", "style": "normal", "weight": "400", "lineHeight": "16px" }, - "tooltipDateColor": "rgba(0, 0, 0, 0.76)", - "tooltipDateInterval": true, - "tooltipHideZeroValues": true, - "tooltipBackgroundColor": "rgba(255, 255, 255, 0.76)", - "tooltipBackgroundBlur": 4, - "animation": { - "animation": true, - "animationThreshold": 2000, - "animationDuration": 300, - "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 - } - }, + "tooltipLabelColor": "rgba(0, 0, 0, 0.76)", + "tooltipHideZeroValues": null, "padding": "12px" }, - "title": "{i18n:api-usage.processing-failures-and-timeouts}", + "title": "{i18n:api-usage.sms-messages-monthly-activity}", "dropShadow": true, "enableFullscreen": true, "titleStyle": null, @@ -8362,12 +11187,317 @@ "displayTypePrefix": true }, "margin": "0px", - "borderRadius": "0px", + "borderRadius": "4px", "iconSize": "0px" }, "row": 0, "col": 0, - "id": "2ee89893-4e38-5331-95b7-3fd4f310c5a7" + "id": "2e7326ac-98d3-e68c-b7cf-948118a3f140" + }, + "07e3a570-c961-b72d-3371-5b29f3617b73": { + "typeFullFqn": "system.api_usage", + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "config": { + "datasources": [ + { + "type": "entity", + "name": "", + "dataKeys": [] + } + ], + "showTitle": true, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0", + "settings": { + "dsEntityAliasId": "40193437-33ac-3172-eefd-0b08eb849062", + "apiUsageDataKeys": [ + { + "label": "{i18n:api-usage.transport-messages}", + "state": "transport_messages", + "status": { + "name": "transportApiState", + "label": "transportApiState", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "maxLimit": { + "name": "transportMsgLimit", + "label": "transportMsgLimit", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "current": { + "name": "transportMsgCount", + "label": "transportMsgCount", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + } + }, + { + "label": "{i18n:api-usage.transport-data-points}", + "state": "transport_data_points", + "status": { + "name": "transportApiState", + "label": "transportApiState", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "maxLimit": { + "name": "transportDataPointsLimit", + "label": "transportDataPointsLimit", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "current": { + "name": "transportDataPointsCount", + "label": "transportDataPointsCount", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + } + }, + { + "label": "{i18n:api-usage.rule-engine-executions}", + "state": "rule_engine_executions", + "status": { + "name": "ruleEngineApiState", + "label": "ruleEngineApiState", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "maxLimit": { + "name": "ruleEngineExecutionLimit", + "label": "ruleEngineExecutionLimit", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "current": { + "name": "ruleEngineExecutionCount", + "label": "ruleEngineExecutionCount", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + } + }, + { + "label": "{i18n:api-usage.javascript-function-executions}", + "state": "javascript_function_executions", + "status": { + "name": "jsExecutionApiState", + "label": "jsExecutionApiState", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "maxLimit": { + "name": "jsExecutionLimit", + "label": "jsExecutionLimit", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "current": { + "name": "jsExecutionCount", + "label": "jsExecutionCount", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + } + }, + { + "label": "{i18n:api-usage.tbel-function-executions}", + "state": "tbel_function_executions", + "status": { + "name": "tbelExecutionApiState", + "label": "tbelExecutionApiState", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "maxLimit": { + "name": "tbelExecutionLimit", + "label": "tbelExecutionLimit", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "current": { + "name": "tbelExecutionCount", + "label": "tbelExecutionCount", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + } + }, + { + "label": "{i18n:api-usage.data-points-storage-days}", + "state": "data_points_storage_days", + "status": { + "name": "dbApiState", + "label": "dbApiState", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "maxLimit": { + "name": "storageDataPointsLimit", + "label": "storageDataPointsLimit", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "current": { + "name": "storageDataPointsCount", + "label": "storageDataPointsCount", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + } + }, + { + "label": "{i18n:api-usage.alarms-created}", + "state": "alarms_created", + "status": { + "name": "alarmApiState", + "label": "alarmApiState", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "maxLimit": { + "name": "createdAlarmsLimit", + "label": "createdAlarmsLimit", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "current": { + "name": "createdAlarmsCount", + "label": "createdAlarmsCount", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + } + }, + { + "label": "{i18n:api-usage.emails}", + "state": "emails", + "status": { + "name": "emailApiState", + "label": "emailApiState", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "maxLimit": { + "name": "emailLimit", + "label": "emailLimit", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "current": { + "name": "emailCount", + "label": "emailCount", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + } + }, + { + "label": "{i18n:api-usage.sms}", + "state": "sms", + "status": { + "name": "notificationApiState", + "label": "notificationApiState", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "maxLimit": { + "name": "smsLimit", + "label": "smsLimit", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + }, + "current": { + "name": "smsCount", + "label": "smsCount", + "type": "timeseries", + "settings": {}, + "color": "#2196f3" + } + } + ], + "targetDashboardState": "default", + "background": { + "type": "color", + "color": "#fff", + "overlay": { + "enabled": false, + "color": "rgba(255,255,255,0.72)", + "blur": 3 + } + }, + "padding": "0" + }, + "title": "{i18n:api-usage.api-usage}", + "decimals": null, + "showTitleIcon": false, + "titleTooltip": "", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": {}, + "widgetCss": ".tb-widget-header {\n height: 48px;\n align-items: center !important;\n padding: 5px 10px 0 10px;\n}", + "titleStyle": {}, + "pageSize": 1024, + "noDataDisplayMessage": "", + "actions": { + "headerButton": [ + { + "name": "{i18n:widgets.api-usage.go-to-main-state}", + "buttonType": "stroked", + "showIcon": false, + "icon": "undo", + "buttonColor": "#305680", + "buttonBorderColor": "#0000001F", + "customButtonStyle": { + "padding": "0 16px" + }, + "useShowWidgetActionFunction": true, + "showWidgetActionFunction": "return widgetContext.stateController.getStateId() !== widgetContext.settings.targetDashboardState && widgetContext.settings.targetDashboardState;", + "type": "custom", + "customFunction": "const state = widgetContext.settings.targetDashboardState?.length ? widgetContext.settings.targetDashboardState : 'default';\nwidgetContext.stateController.updateState(state, widgetContext.stateController.getStateParams(), false);", + "openInSeparateDialog": false, + "openInPopover": false, + "id": "1ea1cca6-47d1-3539-d051-9535129fb12b" + } + ] + }, + "titleFont": { + "size": 16, + "sizeUnit": "px", + "family": null, + "weight": "500", + "style": null, + "lineHeight": "21px" + }, + "borderRadius": "4px" + }, + "row": 0, + "col": 0, + "id": "07e3a570-c961-b72d-3371-5b29f3617b73" } }, "states": { @@ -8377,346 +11507,843 @@ "layouts": { "main": { "widgets": { - "aab68ab5-8e40-8694-c55c-8eb1c89b88fb": { - "sizeX": 4, - "sizeY": 2, + "07e3a570-c961-b72d-3371-5b29f3617b73": { + "sizeX": 24, + "sizeY": 39, "row": 0, "col": 0 - }, - "a84fa70a-ddfa-3b24-9aa4-cf9ce91f919a": { - "sizeX": 4, - "sizeY": 2, + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 12, + "margin": 8, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": true, + "mobileRowHeight": 100, + "outerMargin": true, + "layoutType": "divider", + "minColumns": 12, + "viewFormat": "grid", + "rowHeight": 70, + "layoutDimension": { + "type": "percentage", + "leftWidthPercentage": 30 + } + } + }, + "right": { + "widgets": { + "85240e8c-7af7-90a9-ad0a-726013c479a6": { + "sizeX": 7, + "sizeY": 5, "row": 0, - "col": 4 + "col": 0, + "resizable": true, + "mobileHeight": 6 }, - "d70d26d4-e22d-4ca9-9ea7-f9c87c093321": { - "sizeX": 4, - "sizeY": 2, + "d0a10a8f-8f48-f9d6-8306-d12af9b49690": { + "sizeX": 7, + "sizeY": 5, "row": 0, - "col": 8 + "col": 7, + "resizable": true, + "mobileHeight": 6 + }, + "4544080d-9b6f-b592-9cd4-0e0335d33857": { + "sizeX": 7, + "sizeY": 5, + "row": 5, + "col": 0, + "resizable": true, + "mobileHeight": 6 + }, + "5d0f2f57-499d-1324-8e1b-cfbc0b3149d2": { + "sizeX": 7, + "sizeY": 5, + "row": 5, + "col": 7, + "resizable": true, + "mobileHeight": 6 + } + }, + "gridSettings": { + "layoutType": "divider", + "backgroundColor": "#eeeeee", + "columns": 12, + "margin": 8, + "outerMargin": true, + "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", + "autoFillHeight": true, + "rowHeight": 70, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70, + "mobileDisplayLayoutFirst": false + } + } + } + }, + "rule_engine_statistics": { + "name": "{i18n:api-usage.rule-engine-statistics}", + "root": false, + "layouts": { + "main": { + "widgets": { + "a669cf86-e715-efa4-dd9a-b839abf499e9": { + "sizeX": 24, + "sizeY": 5, + "row": 7, + "col": 0, + "resizable": true, + "mobileHeight": 6 }, - "4d3ea95c-3188-9872-1817-2f989c7729e0": { - "sizeX": 4, - "sizeY": 2, + "fa938580-33db-f1b3-fafc-bc3e3784ad57": { + "sizeX": 12, + "sizeY": 7, "row": 0, - "col": 12 + "col": 0, + "resizable": true, + "mobileHeight": 6 }, - "2d0d6ff6-cd59-51d4-b916-38e22cdd0702": { - "sizeX": 4, - "sizeY": 2, + "2ee89893-4e38-5331-95b7-3fd4f310c5a7": { + "sizeX": 12, + "sizeY": 7, "row": 0, - "col": 16 - }, - "120573cc-e246-eb49-7d80-68e5d3b3c0cc": { - "sizeX": 4, - "sizeY": 2, + "col": 12, + "resizable": true, + "mobileHeight": 6 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "color": "rgba(0,0,0,0.870588)", + "columns": 24, + "margin": 8, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70, + "outerMargin": true, + "layoutType": "default", + "minColumns": 24, + "viewFormat": "grid", + "rowHeight": 70 + } + } + } + }, + "transport_messages": { + "name": "{i18n:api-usage.transport-messages}", + "root": false, + "layouts": { + "main": { + "widgets": { + "07e3a570-c961-b72d-3371-5b29f3617b73": { + "sizeX": 24, + "sizeY": 39, "row": 0, - "col": 20 - }, - "63f99d90-23ab-f8c2-3290-1e693ded5a2e": { - "sizeX": 8, - "sizeY": 4, - "row": 2, - "col": 0 - }, - "a2b7e906-2d8a-41a8-99a6-409531bfa743": { - "sizeX": 8, + "col": 0, + "resizable": true, + "mobileHeight": 4 + } + }, + "gridSettings": { + "layoutType": "divider", + "backgroundColor": "#eeeeee", + "columns": 12, + "margin": 8, + "outerMargin": true, + "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", + "autoFillHeight": true, + "rowHeight": 70, + "backgroundImageUrl": null, + "mobileAutoFillHeight": true, + "mobileRowHeight": 70, + "layoutDimension": { + "type": "percentage", + "leftWidthPercentage": 30 + } + } + }, + "right": { + "widgets": { + "fb155957-1af4-233e-e2fb-09e648e75d6e": { + "sizeX": 6, "sizeY": 4, - "row": 2, - "col": 8 + "row": 4, + "col": 0, + "resizable": true, + "mobileHeight": 6 }, - "ca996b66-ab7e-f977-152c-98e4ebf2a901": { - "sizeX": 8, + "4817e33b-87be-5be3-eaca-ca68a2eb4e0c": { + "sizeX": 6, "sizeY": 4, - "row": 2, - "col": 16 + "row": 4, + "col": 6, + "resizable": true, + "mobileHeight": 6 }, - "a3c2f1bb-7d3a-f11c-7b3d-28cd84fdfe34": { - "sizeX": 8, + "85240e8c-7af7-90a9-ad0a-726013c479a6": { + "sizeX": 12, "sizeY": 4, - "row": 6, + "resizable": true, + "row": 0, "col": 0 - }, - "5cebd4f1-ff6e-62f9-025c-8e7583c3d66a": { - "sizeX": 8, - "sizeY": 4, - "row": 6, - "col": 8 - }, - "bc0c8840-a9b5-5583-de7b-9e9450f5d8fe": { - "sizeX": 8, - "sizeY": 4, - "row": 6, - "col": 16 } }, "gridSettings": { + "layoutType": "divider", "backgroundColor": "#eeeeee", - "color": "rgba(0,0,0,0.870588)", - "columns": 24, - "margin": 5, + "columns": 12, + "margin": 8, + "outerMargin": true, "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", "autoFillHeight": true, + "rowHeight": 70, "backgroundImageUrl": null, "mobileAutoFillHeight": false, - "mobileRowHeight": 100, - "outerMargin": true + "mobileRowHeight": 70, + "mobileDisplayLayoutFirst": false } } } }, - "transport": { - "name": "{i18n:api-usage.transport}", + "transport_data_points": { + "name": "{i18n:api-usage.transport-data-points}", "root": false, "layouts": { "main": { "widgets": { - "0b091dc3-eec3-847e-d0ad-fdf12d474e7a": { + "07e3a570-c961-b72d-3371-5b29f3617b73": { "sizeX": 24, - "sizeY": 6, + "sizeY": 39, "row": 0, "col": 0 + } + }, + "gridSettings": { + "layoutType": "divider", + "backgroundColor": "#eeeeee", + "columns": 12, + "margin": 8, + "outerMargin": true, + "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", + "autoFillHeight": true, + "rowHeight": 70, + "backgroundImageUrl": null, + "mobileAutoFillHeight": true, + "mobileRowHeight": 70, + "layoutDimension": { + "type": "percentage", + "leftWidthPercentage": 30 + } + } + }, + "right": { + "widgets": { + "79056202-c92b-1dae-ce49-318ec52e2d3b": { + "sizeX": 6, + "sizeY": 4, + "row": 4, + "col": 0, + "resizable": true, + "mobileHeight": 6 }, - "536d7104-49f8-fde6-5827-61b8419f15ec": { - "sizeX": 24, - "sizeY": 6, - "row": 6, + "966ffee7-ba0d-8e54-f903-e8d015ca8cd2": { + "sizeX": 6, + "sizeY": 4, + "row": 4, + "col": 6, + "resizable": true, + "mobileHeight": 6 + }, + "d0a10a8f-8f48-f9d6-8306-d12af9b49690": { + "sizeX": 12, + "sizeY": 4, + "resizable": true, + "row": 0, "col": 0 } }, "gridSettings": { + "layoutType": "divider", "backgroundColor": "#eeeeee", - "color": "rgba(0,0,0,0.870588)", - "columns": 24, - "margin": 5, + "columns": 12, + "margin": 8, + "outerMargin": true, "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", "autoFillHeight": true, + "rowHeight": 70, "backgroundImageUrl": null, "mobileAutoFillHeight": false, "mobileRowHeight": 70, - "outerMargin": true + "mobileDisplayLayoutFirst": false } } } }, - "rule_engine_execution": { + "rule_engine_executions": { "name": "{i18n:api-usage.rule-engine-executions}", "root": false, "layouts": { "main": { "widgets": { - "c77e417c-ad9d-8e23-3ea1-c75edd653bc0": { + "07e3a570-c961-b72d-3371-5b29f3617b73": { "sizeX": 24, - "sizeY": 6, + "sizeY": 39, "row": 0, "col": 0 + } + }, + "gridSettings": { + "layoutType": "divider", + "backgroundColor": "#eeeeee", + "columns": 12, + "margin": 8, + "outerMargin": true, + "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", + "autoFillHeight": true, + "rowHeight": 70, + "backgroundImageUrl": null, + "mobileAutoFillHeight": true, + "mobileRowHeight": 70, + "layoutDimension": { + "type": "percentage", + "leftWidthPercentage": 30 + } + } + }, + "right": { + "widgets": { + "84fbe63a-bcb6-7bc1-8af0-46b3b1ee5adc": { + "sizeX": 6, + "sizeY": 4, + "row": 4, + "col": 0, + "resizable": true, + "mobileHeight": 6 }, - "870904d2-d2e1-a1b9-ce56-b03fd47259b5": { - "sizeX": 24, - "sizeY": 6, - "row": 6, + "43a2b982-6c02-d9bd-71ee-34e8e6cf8893": { + "sizeX": 6, + "sizeY": 4, + "row": 4, + "col": 6, + "resizable": true, + "mobileHeight": 6 + }, + "4544080d-9b6f-b592-9cd4-0e0335d33857": { + "sizeX": 12, + "sizeY": 4, + "resizable": true, + "row": 0, "col": 0 } }, "gridSettings": { + "layoutType": "divider", "backgroundColor": "#eeeeee", - "color": "rgba(0,0,0,0.870588)", - "columns": 24, - "margin": 5, + "columns": 12, + "margin": 8, + "outerMargin": true, "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", "autoFillHeight": true, + "rowHeight": 70, "backgroundImageUrl": null, "mobileAutoFillHeight": false, "mobileRowHeight": 70, - "outerMargin": true + "mobileDisplayLayoutFirst": false } } } }, - "telemetry_persistence": { - "name": "{i18n:api-usage.telemetry-persistence}", + "javascript_function_executions": { + "name": "{i18n:api-usage.javascript-function-executions}", "root": false, "layouts": { "main": { "widgets": { - "7f4100d2-41be-4954-d353-1d45000dbbbb": { + "07e3a570-c961-b72d-3371-5b29f3617b73": { "sizeX": 24, - "sizeY": 6, + "sizeY": 39, "row": 0, "col": 0 + } + }, + "gridSettings": { + "layoutType": "divider", + "backgroundColor": "#eeeeee", + "columns": 12, + "margin": 8, + "outerMargin": true, + "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", + "autoFillHeight": true, + "rowHeight": 70, + "backgroundImageUrl": null, + "mobileAutoFillHeight": true, + "mobileRowHeight": 70, + "layoutDimension": { + "type": "percentage", + "leftWidthPercentage": 30 + } + } + }, + "right": { + "widgets": { + "76fe83c9-c30f-00a5-6299-40c759ca6705": { + "sizeX": 12, + "sizeY": 4, + "row": 0, + "col": 0, + "resizable": true, + "mobileHeight": 6 }, - "226ef8c9-8488-3664-21ac-0b6217336202": { - "sizeX": 24, - "sizeY": 6, - "row": 6, - "col": 0 + "a43598d1-7bfd-f329-ee61-c343f34f069f": { + "sizeX": 6, + "sizeY": 4, + "row": 4, + "col": 0, + "resizable": true, + "mobileHeight": 6 + }, + "3ebd62a8-dcb7-c96b-8571-e61084248f5b": { + "sizeX": 6, + "sizeY": 4, + "row": 4, + "col": 6, + "resizable": true, + "mobileHeight": 6 } }, "gridSettings": { + "layoutType": "divider", "backgroundColor": "#eeeeee", - "color": "rgba(0,0,0,0.870588)", - "columns": 24, - "margin": 5, + "columns": 12, + "margin": 8, + "outerMargin": true, "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", "autoFillHeight": true, + "rowHeight": 70, "backgroundImageUrl": null, "mobileAutoFillHeight": false, "mobileRowHeight": 70, - "outerMargin": true + "mobileDisplayLayoutFirst": false } } } }, - "rule_engine_statistics": { - "name": "{i18n:api-usage.rule-engine-statistics}", + "tbel_function_executions": { + "name": "{i18n:api-usage.tbel-function-executions}", "root": false, "layouts": { "main": { "widgets": { - "a669cf86-e715-efa4-dd9a-b839abf499e9": { + "07e3a570-c961-b72d-3371-5b29f3617b73": { "sizeX": 24, - "sizeY": 5, - "row": 7, + "sizeY": 39, + "row": 0, "col": 0 - }, - "fa938580-33db-f1b3-fafc-bc3e3784ad57": { + } + }, + "gridSettings": { + "layoutType": "divider", + "backgroundColor": "#eeeeee", + "columns": 12, + "margin": 8, + "outerMargin": true, + "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", + "autoFillHeight": true, + "rowHeight": 70, + "backgroundImageUrl": null, + "mobileAutoFillHeight": true, + "mobileRowHeight": 70, + "layoutDimension": { + "type": "percentage", + "leftWidthPercentage": 30 + } + } + }, + "right": { + "widgets": { + "88e25971-e5cb-eebb-3c7c-1ce33a8a38f4": { "sizeX": 12, - "sizeY": 7, + "sizeY": 4, + "row": 0, + "col": 0, + "resizable": true, + "mobileHeight": 6 + }, + "a1b5731c-e3b3-8cfb-7c50-3abcdce891d2": { + "sizeX": 6, + "sizeY": 4, + "row": 4, + "col": 0, + "resizable": true, + "mobileHeight": 6 + }, + "efc8d4e9-dee2-b677-c378-c1a666543bf4": { + "sizeX": 6, + "sizeY": 4, + "row": 4, + "col": 6, + "resizable": true, + "mobileHeight": 6 + } + }, + "gridSettings": { + "layoutType": "divider", + "backgroundColor": "#eeeeee", + "columns": 12, + "margin": 8, + "outerMargin": true, + "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", + "autoFillHeight": true, + "rowHeight": 70, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 70, + "mobileDisplayLayoutFirst": false + } + } + } + }, + "data_points_storage_days": { + "name": "{i18n:api-usage.data-points-storage-days}", + "root": false, + "layouts": { + "main": { + "widgets": { + "07e3a570-c961-b72d-3371-5b29f3617b73": { + "sizeX": 24, + "sizeY": 39, "row": 0, "col": 0 + } + }, + "gridSettings": { + "layoutType": "divider", + "backgroundColor": "#eeeeee", + "columns": 12, + "margin": 8, + "outerMargin": true, + "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", + "autoFillHeight": true, + "rowHeight": 70, + "backgroundImageUrl": null, + "mobileAutoFillHeight": true, + "mobileRowHeight": 70, + "layoutDimension": { + "type": "percentage", + "leftWidthPercentage": 30 + } + } + }, + "right": { + "widgets": { + "1249d3e2-6b3a-4e4a-65e9-6ed22959871e": { + "sizeX": 6, + "sizeY": 4, + "row": 4, + "col": 0, + "resizable": true, + "mobileHeight": 6 }, - "2ee89893-4e38-5331-95b7-3fd4f310c5a7": { + "c2f2da29-741d-54f6-5f1d-6f6ae616ea02": { + "sizeX": 6, + "sizeY": 4, + "row": 4, + "col": 6, + "resizable": true, + "mobileHeight": 6 + }, + "5d0f2f57-499d-1324-8e1b-cfbc0b3149d2": { "sizeX": 12, - "sizeY": 7, + "sizeY": 4, + "resizable": true, "row": 0, - "col": 12 + "col": 0 } }, "gridSettings": { + "layoutType": "divider", "backgroundColor": "#eeeeee", - "color": "rgba(0,0,0,0.870588)", - "columns": 24, - "margin": 5, + "columns": 12, + "margin": 8, + "outerMargin": true, "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", "autoFillHeight": true, + "rowHeight": 70, "backgroundImageUrl": null, "mobileAutoFillHeight": false, "mobileRowHeight": 70, - "outerMargin": true + "mobileDisplayLayoutFirst": false } } } }, - "notifications": { - "name": "{i18n:api-usage.notifications-email-sms}", + "emails": { + "name": "{i18n:api-usage.emails}", "root": false, "layouts": { "main": { "widgets": { - "36fdf999-ca22-9a4c-269d-3f004d792792": { - "sizeX": 12, - "sizeY": 6, + "07e3a570-c961-b72d-3371-5b29f3617b73": { + "sizeX": 24, + "sizeY": 39, "row": 0, "col": 0 - }, - "9a191755-499d-535e-86c5-061102729c02": { + } + }, + "gridSettings": { + "layoutType": "divider", + "backgroundColor": "#eeeeee", + "columns": 12, + "margin": 8, + "outerMargin": true, + "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", + "autoFillHeight": true, + "rowHeight": 70, + "backgroundImageUrl": null, + "mobileAutoFillHeight": true, + "mobileRowHeight": 70, + "layoutDimension": { + "type": "percentage", + "leftWidthPercentage": 30 + } + } + }, + "right": { + "widgets": { + "407f7630-406e-9c24-cb3d-b1cbdd190f15": { "sizeX": 12, - "sizeY": 6, + "sizeY": 4, "row": 0, - "col": 12 + "col": 0, + "resizable": true, + "mobileHeight": 6 }, - "4b266318-8357-33ef-ca5a-74cbf90e014f": { - "sizeX": 12, - "sizeY": 6, - "row": 6, - "col": 0 + "b12fb875-89fe-af4c-b344-bf4178de419f": { + "sizeX": 6, + "sizeY": 4, + "row": 4, + "col": 0, + "resizable": true, + "mobileHeight": 6 }, - "5aa33b0b-3bd5-7fe7-ee72-f564c2ca79d8": { - "sizeX": 12, - "sizeY": 6, - "row": 6, - "col": 12 + "0b00099d-d131-3e8b-97ce-c4b8d7bcab1f": { + "sizeX": 6, + "sizeY": 4, + "row": 4, + "col": 6, + "resizable": true, + "mobileHeight": 6 } }, "gridSettings": { + "layoutType": "divider", "backgroundColor": "#eeeeee", - "color": "rgba(0,0,0,0.870588)", - "columns": 24, - "margin": 5, + "columns": 12, + "margin": 8, + "outerMargin": true, "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", "autoFillHeight": true, + "rowHeight": 70, "backgroundImageUrl": null, "mobileAutoFillHeight": false, "mobileRowHeight": 70, - "outerMargin": true + "mobileDisplayLayoutFirst": false } } } }, - "alarms_created": { - "name": "{i18n:api-usage.alarms-created}", + "sms": { + "name": "{i18n:api-usage.sms}", "root": false, "layouts": { "main": { "widgets": { - "bef6c27b-9fe7-ee92-40d9-9696c501a1f9": { + "07e3a570-c961-b72d-3371-5b29f3617b73": { "sizeX": 24, - "sizeY": 6, + "sizeY": 39, "row": 0, "col": 0 + } + }, + "gridSettings": { + "layoutType": "divider", + "backgroundColor": "#eeeeee", + "columns": 12, + "margin": 8, + "outerMargin": true, + "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", + "autoFillHeight": true, + "rowHeight": 70, + "backgroundImageUrl": null, + "mobileAutoFillHeight": true, + "mobileRowHeight": 70, + "layoutDimension": { + "type": "percentage", + "leftWidthPercentage": 30 + } + } + }, + "right": { + "widgets": { + "5648a56e-5a33-3018-92bd-d8e3dbe8aeee": { + "sizeX": 12, + "sizeY": 4, + "row": 0, + "col": 0, + "resizable": true, + "mobileHeight": 6 }, - "52305cf8-2258-5745-a0e7-41a171594bb3": { - "sizeX": 24, - "sizeY": 6, - "row": 6, - "col": 0 + "ab5518c1-34d6-7e17-04b4-6520496d5fe1": { + "sizeX": 6, + "sizeY": 4, + "row": 4, + "col": 0, + "resizable": true, + "mobileHeight": 6 + }, + "2e7326ac-98d3-e68c-b7cf-948118a3f140": { + "sizeX": 6, + "sizeY": 4, + "row": 4, + "col": 6, + "resizable": true, + "mobileHeight": 6 } }, "gridSettings": { + "layoutType": "divider", "backgroundColor": "#eeeeee", - "color": "rgba(0,0,0,0.870588)", - "columns": 24, - "margin": 5, + "columns": 12, + "margin": 8, + "outerMargin": true, "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", "autoFillHeight": true, + "rowHeight": 70, "backgroundImageUrl": null, "mobileAutoFillHeight": false, "mobileRowHeight": 70, - "outerMargin": true + "mobileDisplayLayoutFirst": false } } } }, - "script_functions": { - "name": "{i18n:api-usage.scripts}", + "alarms_created": { + "name": "{i18n:api-usage.alarms-created}", "root": false, "layouts": { "main": { "widgets": { - "c66e5060-57fd-11e7-6616-65b82c294ac2": { + "07e3a570-c961-b72d-3371-5b29f3617b73": { "sizeX": 24, - "sizeY": 6, + "sizeY": 39, "row": 0, "col": 0 + } + }, + "gridSettings": { + "layoutType": "divider", + "backgroundColor": "#eeeeee", + "columns": 12, + "margin": 8, + "outerMargin": true, + "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", + "autoFillHeight": true, + "rowHeight": 70, + "backgroundImageUrl": null, + "mobileAutoFillHeight": true, + "mobileRowHeight": 70, + "layoutDimension": { + "type": "percentage", + "leftWidthPercentage": 30 + } + } + }, + "right": { + "widgets": { + "8e07dbe5-aa7a-19c1-c470-5f055df948a7": { + "sizeX": 12, + "sizeY": 4, + "row": 0, + "col": 0, + "resizable": true, + "mobileHeight": 6 }, - "d0e8603e-5d2e-9287-e2c6-8ccbe9c66806": { - "sizeX": 24, - "sizeY": 6, - "row": 6, - "col": 0 + "e0fe9887-d61c-7813-05a7-f60811e5c5bf": { + "sizeX": 6, + "sizeY": 4, + "row": 4, + "col": 0, + "resizable": true, + "mobileHeight": 6 + }, + "99a40c35-c232-16c5-c42f-3cc80ddb9243": { + "sizeX": 6, + "sizeY": 4, + "row": 4, + "col": 6, + "resizable": true, + "mobileHeight": 6 } }, "gridSettings": { + "layoutType": "divider", "backgroundColor": "#eeeeee", - "color": "rgba(0,0,0,0.870588)", - "columns": 24, - "margin": 5, + "columns": 12, + "margin": 8, + "outerMargin": true, "backgroundSizeMode": "100%", + "minColumns": 12, + "viewFormat": "grid", "autoFillHeight": true, + "rowHeight": 70, "backgroundImageUrl": null, "mobileAutoFillHeight": false, "mobileRowHeight": 70, - "outerMargin": true + "mobileDisplayLayoutFirst": false } } } @@ -8743,9 +12370,6 @@ }, "filters": {}, "timewindow": { - "hideInterval": false, - "hideLastInterval": false, - "hideQuickInterval": false, "hideAggregation": false, "hideAggInterval": false, "hideTimezone": false, @@ -8776,7 +12400,7 @@ "dashboardLogoUrl": null, "hideToolbar": false, "showUpdateDashboardImage": false, - "dashboardCss": ".card .bars-row {\n flex: 1;\n display: flex;\n flex-direction: row;\n}\n\n.card .bar-column {\n flex: 1;\n display: flex;\n flex-direction: column;\n}\n\n\n.card {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n}\n\n.card > img {\n height: 0;\n}\n\n.card .content {\n flex: 1; \n padding: 12px 12px 0;\n display: flex;\n box-sizing: border-box;\n}\n\n.card .content .column {\n display: flex;\n flex-direction: column; \n justify-content: space-around;\n flex: 1;\n}\n\n.card .content .title-row {\n display: flex;\n flex-direction: row;\n padding-bottom: 10px;\n}\n\n.card .title {\n flex: 1;\n font-size: 20px;\n font-weight: 400;\n color: #666666;\n}\n\n.card .state {\n text-transform: uppercase;\n font-size: 20px;\n font-weight: bold;\n}\n\n.card.enabled .state {\n color: #00B260;\n}\n\n.card.warning .state {\n color: #FFAD6F;\n}\n\n.card.disabled .state {\n color: #F73243;\n}\n\n.card .bar-container {\n flex: 1;\n display: flex;\n flex-direction: column;\n justify-content: center;\n}\n\n.card .bar {\n flex: 1;\n max-height: 30px;\n margin-top: 3.5px;\n margin-bottom: 4px;\n background-color: #F0F0F0;\n border: 1px solid #DADCDB;\n border-radius: 2px;\n box-shadow: inset 0 1px 3px rgba(0, 0, 0, .2);\n}\n\n.card.enabled .bar {\n border-color: #00B260;\n background-color: #F0FBF7;\n}\n\n.card.warning .bar {\n border-color: #FFAD6F;\n background-color: #FFFAF6;\n}\n\n.card.disabled .bar {\n border-color: #F73243;\n background-color: #FFF0F0;\n}\n\n.card .bar .bar-fill {\n background-color: #F0F0F0;\n border-radius: 2px;\n height: 100%;\n width: 0%;\n}\n\n.card.enabled .bar-fill {\n background-color: #00C46C;\n}\n\n.card.warning .bar-fill {\n background-color: #FFD099;\n}\n\n.card.disabled .bar-fill {\n background-color: #FF9494;\n}\n\n.card .bar-labels {\n height: 20px;\n font-size: 16px;\n color: #666;\n display: flex;\n flex-direction: row;\n}\n\n\n.card .mat-mdc-button-base {\n text-transform: uppercase;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.card .mdc-button__label {\n pointer-events: none;\n}\n\n.action-row {\n display: flex;\n flex-direction: row;\n justify-content: flex-end;\n padding: 8px 0;\n}\n\n.card .unit {\n color: #666666;\n}\n\n@media screen and (min-width: 960px) and (max-width: 1279px) {\n .card .title {\n font-size: 12px;\n }\n .card .state {\n font-size: 12px;\n }\n .card .unit {\n font-size: 8px;\n }\n .card .bar-labels {\n font-size: 8px;\n }\n .card .mat-mdc-button-base {\n font-size: 8px;\n }\n .card .action-row {\n padding: 0;\n }\n}\n\n@media screen and (min-width: 1280px) and (max-width: 1599px) {\n .card .title {\n font-size: 14px;\n }\n .card .state {\n font-size: 14px;\n }\n .card .unit {\n font-size: 10px;\n }\n .card .bar-labels {\n font-size: 10px;\n }\n .card .mat-mdc-button-base {\n font-size: 10px;\n }\n .card .action-row {\n padding: 0;\n }\n}\n\n@media screen and (min-width: 1600px) and (max-width: 1919px) {\n .card .title {\n font-size: 16px;\n }\n .card .state {\n font-size: 16px;\n }\n .card .unit {\n font-size: 12px;\n }\n .card .bar-labels {\n font-size: 12px;\n }\n .card .mat-mdc-button-base {\n font-size: 12px;\n }\n .card .action-row {\n padding: 0;\n }\n} " + "dashboardCss": "" } }, "name": "Api Usage" 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 fd368e2853..1866ab7d0b 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -865,15 +865,18 @@ "api-features": "API features", "api-usage": "API usage", "alarm": "Alarm", - "alarms-created": "Alarms created", + "alarms-created": "Created alarms", "queue-stats": "Queue Stats", "processing-failures-and-timeouts": "Processing Failures and Timeouts", "exceptions": "Exceptions", - "alarms-created-daily-activity": "Alarms created daily activity", - "alarms-created-hourly-activity": "Alarms created hourly activity", - "alarms-created-monthly-activity": "Alarms created monthly activity", + "alarms-created-daily-activity": "Created alarms daily activity", + "alarms-created-hourly-activity": "Created alarms hourly activity", + "alarms-created-monthly-activity": "Created alarms monthly activity", "data-points": "Data points", "data-points-storage-days": "Data points storage days", + "data-points-storage-days-hourly-activity": "Data points storage days hourly activity", + "data-points-storage-days-daily-activity": "Data points storage days daily activity", + "data-points-storage-days-monthly-activity": "Data points storage days monthly activity", "device-api": "Device API", "email": "Email", "email-messages": "Email messages", @@ -899,14 +902,15 @@ "processing-timeouts": "${entityName} Processing Timeouts", "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-daily-activity": "Rule Engine daily 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-hourly-activity": "SMS messages hourly activity", "sms-messages-daily-activity": "SMS messages daily activity", "sms-messages-monthly-activity": "SMS messages monthly activity", "successful": "${entityName} Successful", @@ -916,13 +920,40 @@ "telemetry-persistence-hourly-activity": "Telemetry persistence hourly activity", "telemetry-persistence-monthly-activity": "Telemetry persistence monthly activity", "transport": "Transport", + "transport-msg-hourly-activity": "Transport messages hourly activity", + "transport-msg-daily-activity": "Transport messages daily activity", + "transport-msg-monthly-activity": "Transport messages monthly activity", "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", + "transport-data-points-hourly-activity": "Transport data points hourly activity", + "transport-data-points-daily-activity": "Transport data points daily activity", + "transport-data-points-monthly-activity": "Transport data points monthly activity", "view-details": "View details", - "view-statistics": "View statistics" + "view-statistics": "View statistics", + "transport-messages": "Transport messages", + "transport-messages-hourly-activity": "Transport messages hourly activity", + "transport-data-point-hourly-activity": "Transport data point hourly activity", + "javascript-function-executions": "JavaScript function executions", + "javascript-function-executions-hourly-activity": "JavaScript function executions hourly activity", + "javascript-function-executions-daily-activity": "JavaScript function executions daily activity", + "javascript-function-executions-monthly-activity": "JavaScript function executions monthly activity", + "tbel-function-executions": "TBEL function executions", + "tbel-function-executions-hourly-activity": "TBEL function executions hourly activity", + "tbel-function-executions-daily-activity": "TBEL function executions daily activity", + "tbel-function-executions-monthly-activity": "TBEL function executions monthly activity", + "created-reports": "Created reports", + "created-reports-hourly-activity": "Created reports hourly activity", + "created-reports-daily-activity": "Created reports daily activity", + "created-reports-monthly-activity": "Created reports monthly activity", + "emails": "Emails", + "emails-hourly-activity": "Emails hourly activity", + "emails-daily-activity": "Emails daily activity", + "emails-monthly-activity": "Emails monthly activity", + "status": { + "enabled": "Enabled", + "disabled": "Disabled", + "warning": "Warning" + } }, "api-limit": { "cassandra-write-queries-core": "Rest API Cassandra write queries", @@ -1021,12 +1052,14 @@ "selected-fields": "{ count, plural, =1 {1 calculated field} other {# calculated fields} } selected", "type": { "simple": "Simple", - "script": "Script" + "script": "Script", + "geofencing" : "Geofencing" }, "arguments": "Arguments", "decimals-by-default": "Decimals by default", "debugging": "Calculated field debugging", "argument-name": "Argument name", + "name": "Name", "datasource": "Datasource", "add-argument": "Add argument", "test-script-function": "Test script function", @@ -1038,6 +1071,7 @@ "argument-asset": "Asset", "argument-customer": "Customer", "argument-tenant": "Current tenant", + "argument-relation-query": "Related entities", "argument-type": "Argument type", "see-debug-events": "See debug events", "attribute": "Attribute", @@ -1071,6 +1105,34 @@ "delete-multiple-text": "Be careful, after the confirmation all selected calculated fields will be removed and all related data will become unrecoverable.", "test-with-this-message": "Test with this message", "use-latest-timestamp": "Use latest timestamp", + "entity-coordinates": "Entity coordinates", + "latitude-time-series-key": "Latitude time series key", + "latitude-time-series-key-required": "Latitude time series key is required.", + "longitude-time-series-key": "Longitude time series key", + "longitude-time-series-key-required": "Longitude time series key is required.", + "geofencing-zone-groups": "Geofencing zone groups", + "geofencing-zone-groups-settings": "Geofencing zone group settings", + "target-zone": "Target zone", + "perimeter-key": "Perimeter key", + "report-strategy": "Report strategy", + "no-zone-configured": "No zone group configured", + "no-zone-configured-required": "At least one zone group must be configured.", + "add-zone-group": "Add zone group", + "report-transition-event-only": "Transition events only", + "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", + "relation-type": "Relation type", + "create-relation-with-matched-zones": "Create relations 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", "hint": { "arguments-simple-with-rolling": "Simple type calculated field should not contain keys with time series rolling type.", "arguments-empty": "Arguments should not be empty.", @@ -1082,12 +1144,32 @@ "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.", + "name-pattern": "Name is invalid.", + "name-duplicate": "Name with such name already exists.", + "name-max-length": "Name should be less than 256 characters.", + "name-forbidden": "Name is reserved and cannot be used.", "argument-type-required": "Argument type is required.", "max-args": "Maximum number of arguments reached.", "decimals-range": "Decimals by default should be a number between 0 and 15.", "expression": "Default expression demonstrates how to transform a temperature from Fahrenheit to Celsius.", "arguments-entity-not-found": "Argument target entity not found.", - "use-latest-timestamp": "If enabled, the calculated value will be persisted using the most recent timestamp from the arguments telemetry, instead of the server time." + "use-latest-timestamp": "If enabled, the calculated value will be persisted using the most recent timestamp from the arguments telemetry, instead of the server time.", + "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.", + "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.", + "relation-level-min": "Minimum relation level value is 1.", + "relation-level-max": "Maximum relation level value is {{max}}.", + "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-required": "Zone groups refresh interval is required.", + "zone-group-refresh-interval-min": "Zone group refresh interval is below the minimum allowed system interval." } }, "ai-models": { @@ -1347,6 +1429,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", @@ -5728,6 +5812,12 @@ "max-arguments-per-cf": "Arguments per calculated field max number", "max-arguments-per-cf-range": "Arguments per calculated field max number can't be negative", "max-arguments-per-cf-required": "Arguments per calculated field max number is required", + "max-related-level-per-argument": "Maximum relation level per 'Related entities' argument", + "max-related-level-per-argument-range": "Relation level per 'Related entities' argument max number can't be less than '1'", + "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-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", "max-state-size-required": "State maximum size in KB is required", @@ -8942,6 +9032,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", @@ -9499,6 +9590,22 @@ "how-to-create-customer-and-assign-dashboard": "How to create Customer and assign Dashboard" } } + }, + "api-usage": { + "api-usage": "API usage", + "label": "Label", + "state-name": "State name", + "status": "Status", + "status-required": "Status is required.", + "limit": "Max limit", + "limit-required": "Max limit is required.", + "current-number": "Current number", + "current-number-required": "Current number is required.", + "add-key": "Add key", + "no-key": "No key", + "delete-key": "Delete key", + "target-dashboard-state": "Target dashboard state", + "go-to-main-state": "Go to default view" } }, "color": { @@ -9507,6 +9614,7 @@ "icon": { "icon": "Icon", "icons": "Icons", + "custom": "Custom", "select-icon": "Select icon", "material-icons": "Material icons", "show-all": "Show all icons", diff --git a/ui-ngx/src/scss/mixins.scss b/ui-ngx/src/scss/mixins.scss index 8e60483cb1..fb4a51ebce 100644 --- a/ui-ngx/src/scss/mixins.scss +++ b/ui-ngx/src/scss/mixins.scss @@ -26,6 +26,10 @@ width: #{$size}px; height: #{$size}px; } + img { + width: #{$size}px; + height: #{$size}px; + } } @mixin tb-mat-icon-button-size($size) { diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index a8cf53534e..6d49e9f698 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -896,7 +896,7 @@ pre.tb-highlight { } .mat-icon { - svg { + svg, img { vertical-align: inherit; } &.tb-mat-12 {