diff --git a/.github/release.yml b/.github/release.yml index 53b2e3ae65..70852dcbb2 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -1,5 +1,5 @@ # -# Copyright © 2016-2025 The Thingsboard Authors +# Copyright © 2016-2026 The Thingsboard Authors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -23,27 +23,19 @@ changelog: labels: - 'Major Core' - 'Major Rule Engine' - exclude: - labels: - - 'Bug' + - title: 'Major UI' labels: - 'Major UI' - exclude: - labels: - - 'Bug' + - title: 'Major Transport' labels: - 'Major Transport' - exclude: - labels: - - 'Bug' + - title: 'Major Edge' labels: - 'Major Edge' - exclude: - labels: - - 'Bug' + - title: 'Core & Rule Engine' labels: - 'Core' @@ -51,38 +43,63 @@ changelog: exclude: labels: - 'Bug' + - title: 'UI' labels: - 'UI' exclude: labels: - 'Bug' + - title: 'Transport' labels: - 'Transport' exclude: labels: - 'Bug' + - title: 'Edge' labels: - 'Edge' exclude: labels: - 'Bug' + - title: 'Bug: Core & Rule Engine' labels: - - 'Core' - - 'Rule Engine' - 'Bug' + exclude: + labels: + - 'UI' + - 'Transport' + - 'Edge' + - title: 'Bug: UI' labels: - - 'UI' - 'Bug' + exclude: + labels: + - 'Core' + - 'Rule Engine' + - 'Transport' + - 'Edge' + - title: 'Bug: Transport' labels: - - 'Transport' - 'Bug' + exclude: + labels: + - 'Core' + - 'Rule Engine' + - 'UI' + - 'Edge' + - title: 'Bug: Edge' labels: - - 'Edge' - 'Bug' + exclude: + labels: + - 'Core' + - 'Rule Engine' + - 'UI' + - 'Transport' diff --git a/.github/workflows/check-configuration-files.yml b/.github/workflows/check-configuration-files.yml index 561b7d0019..0ea9d61923 100644 --- a/.github/workflows/check-configuration-files.yml +++ b/.github/workflows/check-configuration-files.yml @@ -1,5 +1,5 @@ # -# Copyright © 2016-2025 The Thingsboard Authors +# Copyright © 2016-2026 The Thingsboard Authors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/.github/workflows/license-header-format.yml b/.github/workflows/license-header-format.yml index 20a1c1350a..0ac41c7bc8 100644 --- a/.github/workflows/license-header-format.yml +++ b/.github/workflows/license-header-format.yml @@ -1,5 +1,5 @@ # -# Copyright © 2016-2025 The Thingsboard Authors +# Copyright © 2016-2026 The Thingsboard Authors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index bffd87ffa5..6267cc3a7a 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,150 @@ -# ThingsBoard -[![ThingsBoard Builds Server Status](https://img.shields.io/teamcity/build/e/ThingsBoard_Build?label=TB%20builds%20server&server=https%3A%2F%2Fbuilds.thingsboard.io&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAALzAAAC8wHS6QoqAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAB9FJREFUeJzVm3+MXUUVx7+zWwqEtnRLWisQ2lKVUisIQmsqYCohpUhpEGsFKSJJTS0qGiGIISJ/8CNGYzSaEKBQEZUiP7RgVbCVdpE0xYKBWgI2rFLZJZQWtFKobPfjH3Pfdu7s3Pvmzntv3/JNNr3bOXPO+Z6ZO3PumVmjFgEYJWmWpDmSZks6VtIESV3Zv29LWmGMubdVPgw7gEOBJcAaYC/18fd2+zyqngAwXdL7M9keSduMMXgyH5R0laRPSRpbwf62CrLDB8AAS4HnAqP2EvA1YBTwPuBnwP46I70H+DPwALAS+B5wBTCu3VyHIJvG98dMX+B/BW1vAvcAnwdmAp3t5hWFbORXR5AvwmPARcCYdnNJAnCBR+gd7HQ9HZgLfAt4PUB8AzCv3f43DGCTQ6o/RAo43gtCL2Da4W9TAUwEBhxiPymRvcabAR8eTl+biQ7neYokdyTXlvR7xPt9etM8GmZ0FDxL+WD42FdBdkTDJd0jyU1wzi7pd473e0+qA8AM4AbgkrK1BDgOWAc8ChyTaq+eM5ud93ofcHpAZiY2sanhZaDDaTfAZ7HJUmlWCJzm6bqLQM6QBanXkfthcxgPNbTEW9z2AT8AzgTmANdikxwXX/d0XOi0bQEmFNj6GPAfhuKnXkB98kNsNjsITwacKkI3MNrrf4UnswXoiiRfwyqgo4D8L2hVZglMw456DDYCRwR0jCH/KuWCgE2oysjX8KsA+V+2jHzm3CrP4PMBx/4JfAU4qETP+EAQ/gKcA/w7gnwNbl5yD7bG0DLyM7DZXw3d2f9PA+YD5wIzK+gLBSEFA/XIA2cAVwLvbSQAt3mGP5Gs7IDO8dg1ZYDGcAfOwujZuIwDn+ObUx09hHx+v7Eh5nndCyIIDgBbgd0lMiv9IABfIF+LeDnVyU97xj5XR/6bwI5sZEaXyH2UuHd+WSbfRXktYjAIAfL9wGdSA/Cgo+gtSio12IKJa3hNKAgZ+TciyL+AlwECKzI/ioLgTvsa+YtTyXeSz8ZW15E3wN88p3JBwCZNMeShIKkBTsRmmSG4a0o/sDSJfGboBE/5pRF9pgI9oSBUJP8mXpLk2bm6pO9Aw+QzI8s8xVFbXRaEf3h911cgD7Cyjg0/L/GxnoLdoUoA3O1vDxUyLWyO4AehCpYX6D2L/LpUhtsaCkIWxRoeT+g/DVsqT8EWYDowC5jh6FxUUc+tJJblOmSPqWp4JUFHl6TDUoxLOlnSdknPSnK3sA2S9lfQs0zS7SkzwQ/A61U6A6dKWufpSMVg5mmMeUPSXyv2v0zSN6oa7ZAdwRqiA5CRf0TS+KpGAxiQ1OFN4z8l6PErVXUxSvmp1hvTqUnk35adPWskPWSM6fPaq84ASXqscg/gi9gcvJuC6o0nfwrhw5EYvIpNn88HStcN4M6KulfTys/lzKlO0lb8P2Lrf6VbLDAF+DLweEX998aSx372bwP6gPlVA3BEAvm9FJwVYtPqjwDXA08n6AZbOYoeeeAWp++mSlPGGLMLeFjSuRW6Iektx4GDJc2TdJ6khZKOruKDh/skXWSM6a/Q5yjn+dDKFrE1vw0VR2m2039x4kj7uJ+SslyJ/+7rtaly4mCM+a+kBaq2TbnVpfWy216jmCzpkIR+7kK/MymHNsbslX0NYoMweMpsjNklaWuKXQ9zJf2eOocvAbzHee5N/ojIgvBVxY3madh3v4b1iWZ/o3zw5kpaS+SFDGCq8jPguUQ/CmsCZfi403dhwjv/AHAQMAl41mvbGBMEhq4/c1PJTwmQr1f7u97pfzj5EnwUead/KAg/ivD7Zkf+HSBpFwiRfwibI3SXkOj29PgEivAggdU+C8JWR+6+CN9dm1tSyHcBLwbIj87ax1Kcxe0DJmVyY4CdEeR/TXnVeRLwc+C3wHF1fP+Qp/uGlABc6Cl5mPziVi8IzwDfAZ6KIN9LyhQt9v1GT/+sFCXTOVBBXuOTd+TGkp+eqWjKSTBwMPAvR+9TjSibjK35l93mWIxdZFKOxPzFseEgAJd7Olt6v+AC8jdIqwRhLbZM758HRH3tYa/vnoqtKZ4JHIk99tvh6HqNVl3RLSB/JfBEBPnBwxXsJ2uf176qxO7hwE3ALq/PfuyVXhdXt4r8+QHyK7K2cXWCMLiTOPqODwTh2IDdD2CP12LwCnUKMankO8kfiAySd2SKgjCEfEEQ+nznsZc7eyLJA9zddPKZIx0c2NcHgMsL5MZhr83XULiTeCSXAEcG2m4PjPCXsEWWBdhbZ/4h6knN4u07Mxv4MbCojtxo7DW6RTRwopMFxt0xeoCJAblLvCDdlWpzRAG42CO2sET2UUfuVbetsYPF9mKq8zwg6Q8lsm7bRJxt8N0cAPdar5FUupYU9X03B2C782wknVUi+0nneacxZk9rXBpGABO8RXA72demJ7fcWyvubIe/TQN2y11MuJ6wA5v3z8HeMbjba+8n5StwJCDb9lYUEI/Fde3mEQ1svnBKRvp32K/LEPYQd1z3XQJfsG3/Sw/gKElLZev8tb8rnizpBEmF1SDZ06ZbJN0saa+kayQtV77qi6QnJF1njFnXdOebAcIXssvQB3yfcGrcCZwEnAfMC8mMKGArNUVT28VubF4/nyZflx8Jr8BVkr4tm83tzn5ek/S8pM2SnpT0gv8H283C/wGTFfhGtexQwQAAAABJRU5ErkJggg==&labelColor=305680)](https://builds.thingsboard.io/viewType.html?buildTypeId=ThingsBoard_Build&guest=1) +![banner](https://github.com/user-attachments/assets/3584b592-33dd-4fb4-91d4-47b62b34806c) + +
+ +# Open-source IoT platform for data collection, processing, visualization, and device management. + +
+
+
+ +💡 [Get started](https://thingsboard.io/docs/getting-started-guides/helloworld/) • 🌐 [Website](https://thingsboard.io/) • 📚 [Documentation](https://thingsboard.io/docs/) • 📔 [Blog](https://thingsboard.io/blog/) • ▶️ [Live demo](https://demo.thingsboard.io/signup) • 🔗 [LinkedIn](https://www.linkedin.com/company/thingsboard/posts/?feedView=all) + +
+ +## 🚀 Installation options + +* Install ThingsBoard [On-premise](https://thingsboard.io/docs/user-guide/install/installation-options/?ceInstallType=onPremise) +* Try [ThingsBoard Cloud](https://thingsboard.io/installations/) +* or [Use our Live demo](https://demo.thingsboard.io/signup) + +## 💡 Getting started with ThingsBoard + +Check out our [Getting Started guide](https://thingsboard.io/docs/getting-started-guides/helloworld/) or [watch the video](https://www.youtube.com/watch?v=80L0ubQLXsc) to learn the basics of ThingsBoard and create your first dashboard! You will learn to: + +* Connect devices to ThingsBoard +* Push data from devices to ThingsBoard +* Build real-time dashboards +* Create a Customer and assign the dashboard with them. +* Define thresholds and trigger alarms +* Set up notifications via email, SMS, mobile apps, or integrate with third-party services. + +## ✨ Features + + + + + + + + + + +
+
+
+ Provision and manage devices and assets +

Provision and manage
devices and assets

+
+
+

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 ➜ +
+
+
+
+
+ Collect and visualize your data +

Collect and visualize
your data

+
+
+

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 ➜ +
+
+
+
+
+ SCADA Dashboards +

SCADA Dashboards

+
+
+

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 ➜ +
+
+
+
+
+ Process and React +

Process and React

+
+
+

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 ➜ +
+
+
+ +## ⚙️ Powerful IoT Rule Engine + +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/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/) + +
+ +## 📦 Real-Time IoT Dashboards + +ThingsBoard is a scalable, user-friendly, and device-agnostic IoT platform that speeds up time-to-market with powerful built-in solution templates. It enables data collection and analysis from any devices, saving resources on routine tasks and letting you focus on your solution’s unique aspects. See more our Use Cases [here](https://thingsboard.io/iot-use-cases/). + +[**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/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/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/56b84c99-ef24-44e5-a903-b925b7f9d142 "Smart farming")](https://thingsboard.io/use-cases/smart-farming/) -ThingsBoard is an open-source IoT platform for data collection, processing, visualization, and device management. - - - - -## Documentation - -ThingsBoard documentation is hosted on [thingsboard.io](https://thingsboard.io/docs). - -## IoT use cases - -[**Smart energy**](https://thingsboard.io/smart-energy/) -[![Smart energy](https://user-images.githubusercontent.com/8308069/152984256-eb48564a-645c-468d-912b-f554b63104a5.gif "Smart energy")](https://thingsboard.io/smart-energy/) - -[**SCADA Swimming pool**](https://thingsboard.io/use-cases/scada/) -[![SCADA Swimming pool](https://github.com/user-attachments/assets/0878a2f5-d358-47c5-b295-03b4533685cf "SCADA Swimming pool")](https://thingsboard.io/use-cases/scada/) - -[**Fleet tracking**](https://thingsboard.io/fleet-tracking/) -[![Fleet tracking](https://user-images.githubusercontent.com/8308069/152984528-0054ed55-8b8b-4cda-ba45-02fe95a81222.gif "Fleet tracking")](https://thingsboard.io/fleet-tracking/) - -[**Smart farming**](https://thingsboard.io/smart-farming/) -[![Smart farming](https://user-images.githubusercontent.com/8308069/152984443-a98b7d3d-ff7a-4037-9011-e71e1e6f755f.gif "Smart farming")](https://thingsboard.io/smart-farming/) +[**Smart metering**](https://thingsboard.io/smart-metering/) -[**IoT Rule Engine**](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) -[![IoT Rule Engine](https://img.thingsboard.io/demo/send-email-rule-chain.gif "IoT Rule Engine")](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) +[![Smart metering](https://github.com/user-attachments/assets/adc05e3d-397c-48ef-bed6-535bbd698455 "Smart metering")](https://thingsboard.io/smart-metering/) -[**Smart metering**](https://thingsboard.io/smart-metering/) -[![Smart metering](https://user-images.githubusercontent.com/8308069/31455788-6888a948-aec1-11e7-9819-410e0ba785e0.gif "Smart metering")](https://thingsboard.io/smart-metering/) +
-## Getting Started +[**Check more of our use cases ➜**](https://thingsboard.io/iot-use-cases/) -Collect and Visualize your IoT data in minutes by following this [guide](https://thingsboard.io/docs/getting-started-guides/helloworld/). +
-## Support +## 🫶 Support - - [Stackoverflow](http://stackoverflow.com/questions/tagged/thingsboard) +To get support, please visit our [GitHub issues page](https://github.com/thingsboard/thingsboard/issues) -## Licenses +## 📄 Licenses -This project is released under [Apache 2.0 License](./LICENSE). +This project is released under [Apache 2.0 License](./LICENSE) diff --git a/application/pom.xml b/application/pom.xml index 9c683cbd25..ab26655052 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -1,6 +1,6 @@ + + + diff --git a/application/src/main/resources/templates/2fa.verification.code.ftl b/application/src/main/resources/templates/2fa.verification.code.ftl index b8e957431a..7442f3d897 100644 --- a/application/src/main/resources/templates/2fa.verification.code.ftl +++ b/application/src/main/resources/templates/2fa.verification.code.ftl @@ -1,6 +1,6 @@ <#-- - Copyright © 2016-2025 The Thingsboard Authors + Copyright © 2016-2026 The Thingsboard Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/application/src/main/resources/templates/account.activated.ftl b/application/src/main/resources/templates/account.activated.ftl index 3a9974dcc5..49501e353c 100644 --- a/application/src/main/resources/templates/account.activated.ftl +++ b/application/src/main/resources/templates/account.activated.ftl @@ -1,6 +1,6 @@ <#-- - Copyright © 2016-2025 The Thingsboard Authors + Copyright © 2016-2026 The Thingsboard Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/application/src/main/resources/templates/account.lockout.ftl b/application/src/main/resources/templates/account.lockout.ftl index 19515d95d5..a26254d5f1 100644 --- a/application/src/main/resources/templates/account.lockout.ftl +++ b/application/src/main/resources/templates/account.lockout.ftl @@ -1,6 +1,6 @@ <#-- - Copyright © 2016-2025 The Thingsboard Authors + Copyright © 2016-2026 The Thingsboard Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/application/src/main/resources/templates/activation.ftl b/application/src/main/resources/templates/activation.ftl index 57cccec6ea..ac0a675e1e 100644 --- a/application/src/main/resources/templates/activation.ftl +++ b/application/src/main/resources/templates/activation.ftl @@ -1,6 +1,6 @@ <#-- - Copyright © 2016-2025 The Thingsboard Authors + Copyright © 2016-2026 The Thingsboard Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/application/src/main/resources/templates/password.was.reset.ftl b/application/src/main/resources/templates/password.was.reset.ftl index 3de0d2eedd..f12ba1901a 100644 --- a/application/src/main/resources/templates/password.was.reset.ftl +++ b/application/src/main/resources/templates/password.was.reset.ftl @@ -1,6 +1,6 @@ <#-- - Copyright © 2016-2025 The Thingsboard Authors + Copyright © 2016-2026 The Thingsboard Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/application/src/main/resources/templates/reset.password.ftl b/application/src/main/resources/templates/reset.password.ftl index 942800766a..e6e5a59021 100644 --- a/application/src/main/resources/templates/reset.password.ftl +++ b/application/src/main/resources/templates/reset.password.ftl @@ -1,6 +1,6 @@ <#-- - Copyright © 2016-2025 The Thingsboard Authors + Copyright © 2016-2026 The Thingsboard Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/application/src/main/resources/templates/state.disabled.ftl b/application/src/main/resources/templates/state.disabled.ftl index 0f5ce52dca..9f5a69e142 100644 --- a/application/src/main/resources/templates/state.disabled.ftl +++ b/application/src/main/resources/templates/state.disabled.ftl @@ -1,6 +1,6 @@ <#-- - Copyright © 2016-2025 The Thingsboard Authors + Copyright © 2016-2026 The Thingsboard Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/application/src/main/resources/templates/state.enabled.ftl b/application/src/main/resources/templates/state.enabled.ftl index e1baaa12b1..b6f2bab894 100644 --- a/application/src/main/resources/templates/state.enabled.ftl +++ b/application/src/main/resources/templates/state.enabled.ftl @@ -1,6 +1,6 @@ <#-- - Copyright © 2016-2025 The Thingsboard Authors + Copyright © 2016-2026 The Thingsboard Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/application/src/main/resources/templates/state.warning.ftl b/application/src/main/resources/templates/state.warning.ftl index bfb85c3a0a..2b757d7d62 100644 --- a/application/src/main/resources/templates/state.warning.ftl +++ b/application/src/main/resources/templates/state.warning.ftl @@ -1,6 +1,6 @@ <#-- - Copyright © 2016-2025 The Thingsboard Authors + Copyright © 2016-2026 The Thingsboard Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/application/src/main/resources/templates/test.ftl b/application/src/main/resources/templates/test.ftl index d921439d6b..a022e2b451 100644 --- a/application/src/main/resources/templates/test.ftl +++ b/application/src/main/resources/templates/test.ftl @@ -1,6 +1,6 @@ <#-- - Copyright © 2016-2025 The Thingsboard Authors + Copyright © 2016-2026 The Thingsboard Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index cda7f5782a..db8792131b 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1,5 +1,5 @@ # -# Copyright © 2016-2025 The Thingsboard Authors +# Copyright © 2016-2026 The Thingsboard Authors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -100,6 +100,9 @@ server: rate_limits: # Limit that prohibits resetting the password for the user too often. The value of the rate limit. By default, no more than 5 requests per hour reset_password_per_user: "${RESET_PASSWORD_PER_USER_RATE_LIMIT_CONFIGURATION:5:3600}" + rule_engine: + # Default timeout for waiting response of REST API request to Rule Engine in milliseconds + response_timeout: "${DEFAULT_RULE_ENGINE_RESPONSE_TIMEOUT:10000}" # Application info parameters app: @@ -148,6 +151,12 @@ security: tokenSigningKey: "${JWT_TOKEN_SIGNING_KEY:thingsboardDefaultSigningKey}" # Base64 encoded # Enable/disable access to Tenant Administrators JWT token by System Administrator or Customer Users JWT token by Tenant Administrator user_token_access_enabled: "${SECURITY_USER_TOKEN_ACCESS_ENABLED:true}" + # API key parameters + api_key: + # Prefix for the auto-generated API key. For example, tb_Ood4dQMxWvMH-76z3E_Cv0mZaBWT0Clk3hRSO0P_jNQ + value_prefix: "${SECURITY_API_KEY_VALUE_PREFIX:tb_}" + # Length of the auto-generated API key. Max is 255 + value_bytes_size: "${SECURITY_API_KEY_VALUE_PREFIX:64}" # Enable/disable case-sensitive username login user_login_case_sensitive: "${SECURITY_USER_LOGIN_CASE_SENSITIVE:true}" claim: @@ -208,7 +217,7 @@ ui: # Help parameters help: # Base URL for UI help assets - base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-4.2.1 }" + base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-4.3}" # Database telemetry parameters database: @@ -427,6 +436,9 @@ sql: enabled: "${SQL_TTL_NOTIFICATIONS_ENABLED:true}" # Enable/disable TTL (Time To Live) for notification center records ttl: "${SQL_TTL_NOTIFICATIONS_SECS:2592000}" # Default value - 30 days checking_interval_ms: "${SQL_TTL_NOTIFICATIONS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day + api_keys: + enabled: "${SQL_TTL_API_KEYS_ENABLED:true}" # Enable/disable TTL (Time To Live) for expired api keys records + checking_interval_ms: "${SQL_TTL_API_KEYS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day relations: max_level: "${SQL_RELATIONS_MAX_LEVEL:50}" # This value has to be reasonably small to prevent infinite recursion as early as possible pool_size: "${SQL_RELATIONS_POOL_SIZE:4}" # This value has to be reasonably small to prevent the relation query from blocking all other DB calls @@ -504,14 +516,14 @@ actors: response_timeout_ms: "${ACTORS_RPC_RESPONSE_TIMEOUT_MS:30000}" # Close transport session if RPC delivery timed out. If enabled, RPC will be reverted to the queued state. # Note: - # - For MQTT transport: - # - QoS level 0: This feature does not apply, as no acknowledgment is expected, and therefore no timeout is triggered. - # - QoS level 1: This feature applies, as an acknowledgment is expected. - # - QoS level 2: Unsupported. - # - For CoAP transport: - # - Confirmable requests: This feature applies, as delivery confirmation is expected. - # - Non-confirmable requests: This feature does not apply, as no delivery acknowledgment is expected. - # - For HTTP and SNPM transports: RPC is considered delivered immediately, and there is no logic to await acknowledgment. + # close_session_on_rpc_delivery_timeout: "${ACTORS_RPC_CLOSE_SESSION_ON_RPC_DELIVERY_TIMEOUT:false}" statistics: # Enable/disable actor statistics @@ -662,6 +674,9 @@ cache: aiModel: timeToLiveInMinutes: "${CACHE_SPECS_AI_MODEL_TTL:1440}" # AI model cache TTL maxSize: "${CACHE_SPECS_AI_MODEL_MAX_SIZE:10000}" # 0 means the cache is disabled + apiKeys: + timeToLiveInMinutes: "${CACHE_SPECS_API_KEYS_TTL:1440}" # API keys cache TTL + maxSize: "${CACHE_SPECS_API_KEYS_MAX_SIZE:10000}" # 0 means the cache is disabled # Deliberately placed outside the 'specs' group above notificationRules: @@ -682,6 +697,9 @@ cache: tbResourceData: timeToLiveInMinutes: "${CACHE_SPECS_RESOURCE_DATA_TTL:10080}" # TB resource data cache TTL maxSize: "${CACHE_SPECS_RESOURCE_DATA_MAX_SIZE:100000}" # 0 means the cache is disabled + userAuthDetails: + timeToLiveInMinutes: "${CACHE_SPECS_USER_AUTH_DETAILS_TTL:120}" # User auth details cache TTL + maxSize: "${CACHE_SPECS_USER_AUTH_DETAILS_MAX_SIZE:200000}" # 0 means the cache is disabled # Spring data parameters spring.data.redis.repositories.enabled: false # Disable this because it is not required. @@ -715,18 +733,20 @@ redis: # if set false will be used pool config build from values of the pool config section useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}" sentinel: - # name of the master node + # Name of the master node master: "${REDIS_MASTER:}" - # comma-separated list of "host:port" pairs of sentinels + # Comma-separated list of "host:port" pairs of sentinels sentinels: "${REDIS_SENTINELS:}" - # password to authenticate with sentinel + # Password to authenticate with sentinel password: "${REDIS_SENTINEL_PASSWORD:}" - # if set false will be used pool config build from values of the pool config section + # If set false will be used pool config build from values of the pool config section useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}" # db index db: "${REDIS_DB:0}" # db password password: "${REDIS_PASSWORD:}" + # Redis username for ACL authentication (Redis 6.0+). Leave empty for legacy password-only auth + username: "${REDIS_USERNAME:}" # ssl config ssl: # Enable/disable secure connection @@ -1021,10 +1041,10 @@ transport: activity: # This property specifies the strategy for reporting activity events within each reporting period. # The accepted values are 'FIRST', 'LAST', 'FIRST_AND_LAST' and 'ALL'. - # - 'FIRST': Only the first activity event in each reporting period is reported. - # - 'LAST': Only the last activity event in the reporting period is reported. - # - 'FIRST_AND_LAST': Both the first and last activity events in the reporting period are reported. - # - 'ALL': All activity events in the reporting period are reported. + # reporting_strategy: "${TB_TRANSPORT_ACTIVITY_REPORTING_STRATEGY:LAST}" json: # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON @@ -1142,16 +1162,16 @@ transport: dtls: # RFC7925_RETRANSMISSION_TIMEOUT_IN_MILLISECONDS = 9000 retransmission_timeout: "${LWM2M_DTLS_RETRANSMISSION_TIMEOUT_MS:9000}" - # CoAP DTLS connection ID length for LWM2M. RFC 9146, Connection Identifier for DTLS 1.2 - # Default: off + # LWM2M DTLS connection ID length for LWM2M. RFC 9146, Connection Identifier for DTLS 1.2 + # Default: off.
# Control usage of DTLS connection ID length (CID). - # - 'off' to deactivate it. - # - 'on' to activate Connection ID support (same as CID 0 or more 0). - # - A positive value defines generated CID size in bytes. - # - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic). - # - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used - # - A value that are > 4: MultiNodeConnectionIdGenerator is used - connection_id_length: "${LWM2M_DTLS_CONNECTION_ID_LENGTH:}" + # + connection_id_length: "${LWM2M_DTLS_CONNECTION_ID_LENGTH:8}" server: # LwM2M Server ID id: "${LWM2M_SERVER_ID:123}" @@ -1196,7 +1216,7 @@ transport: # Enable/disable Bootstrap Server enabled: "${LWM2M_ENABLED_BS:true}" # Default value in LwM2M client after start in mode Bootstrap for the object : name "LWM2M Security" field: "Short Server ID" (deviceProfile: Bootstrap.BOOTSTRAP SERVER.Short ID) - id: "${LWM2M_SERVER_ID_BS:111}" + id: "${LWM2M_SERVER_ID_BS:0}" # LwM2M bootstrap server bind address. Bind to all interfaces by default bind_address: "${LWM2M_BS_BIND_ADDRESS:0.0.0.0}" # LwM2M bootstrap server bind port @@ -1333,15 +1353,15 @@ coap: # CoAP DTLS bind port bind_port: "${COAP_DTLS_BIND_PORT:5684}" # CoAP DTLS connection ID length. RFC 9146, Connection Identifier for DTLS 1.2 - # Default: off + # Default: off.
# Control usage of DTLS connection ID length (CID). - # - 'off' to deactivate it. - # - 'on' to activate Connection ID support (same as CID 0 or more 0). - # - A positive value defines generated CID size in bytes. - # - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic). - # - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used - # - A value that are > 4: MultiNodeConnectionIdGenerator is used - connection_id_length: "${COAP_DTLS_CONNECTION_ID_LENGTH:}" + # + connection_id_length: "${COAP_DTLS_CONNECTION_ID_LENGTH:8}" # Specify the MTU (Maximum Transmission Unit). # Should be used if LAN MTU is not used, e.g. if IP tunnels are used or if the client uses a smaller value than the LAN MTU. # Default = 1024 @@ -1357,13 +1377,13 @@ coap: # In order to negotiate smaller maximum fragment lengths, # clients MAY include an extension of type "max_fragment_length" in the (extended) client hello. # The "extension_data" field of this extension SHALL contain: - # enum { + #
 enum {
     #   2^9(1) == 512,
     #   2^10(2) == 1024,
     #   2^11(3) == 2048,
     #   2^12(4) == 4096,
     #   (255)
-    # } MaxFragmentLength;
+    # } MaxFragmentLength; 
# TLS already requires clients and servers to support fragmentation of handshake messages. max_fragment_length: "${COAP_DTLS_MAX_FRAGMENT_LENGTH:1024}" # Server DTLS credentials @@ -1449,7 +1469,7 @@ device: pem_cert_file: "${DEVICE_CONNECTIVITY_COAPS_CA_ROOT_CERT:cafile.pem}" gateway: # The docker tag for thingsboard/tb-gateway image used in docker-compose file for gateway launch - image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.7-stable}" + image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.8-stable}" # Edges parameters edges: @@ -1488,6 +1508,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 @@ -1542,6 +1567,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 or list) + doc_expansion: "${SWAGGER_DOC_EXPANSION:list}" # Queue configuration parameters queue: diff --git a/application/src/test/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessorTest.java b/application/src/test/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessorTest.java index 9243e295bc..5695d27162 100644 --- a/application/src/test/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessorTest.java +++ b/application/src/test/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessorTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/actors/rule/DefaultTbContextTest.java b/application/src/test/java/org/thingsboard/server/actors/rule/DefaultTbContextTest.java index bf055ee146..759b7ffa2d 100644 --- a/application/src/test/java/org/thingsboard/server/actors/rule/DefaultTbContextTest.java +++ b/application/src/test/java/org/thingsboard/server/actors/rule/DefaultTbContextTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/actors/service/ComponentActorTest.java b/application/src/test/java/org/thingsboard/server/actors/service/ComponentActorTest.java index 49bf3c8670..441d0adfe5 100644 --- a/application/src/test/java/org/thingsboard/server/actors/service/ComponentActorTest.java +++ b/application/src/test/java/org/thingsboard/server/actors/service/ComponentActorTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/actors/stats/StatsActorTest.java b/application/src/test/java/org/thingsboard/server/actors/stats/StatsActorTest.java index 24102de06b..5c91903fc8 100644 --- a/application/src/test/java/org/thingsboard/server/actors/stats/StatsActorTest.java +++ b/application/src/test/java/org/thingsboard/server/actors/stats/StatsActorTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/actors/stats/StatsPersistMsgTest.java b/application/src/test/java/org/thingsboard/server/actors/stats/StatsPersistMsgTest.java index 80a6b8ad91..d0839e5718 100644 --- a/application/src/test/java/org/thingsboard/server/actors/stats/StatsPersistMsgTest.java +++ b/application/src/test/java/org/thingsboard/server/actors/stats/StatsPersistMsgTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/actors/tenant/TenantActorTest.java b/application/src/test/java/org/thingsboard/server/actors/tenant/TenantActorTest.java index d8fac2b936..cc2266b9cc 100644 --- a/application/src/test/java/org/thingsboard/server/actors/tenant/TenantActorTest.java +++ b/application/src/test/java/org/thingsboard/server/actors/tenant/TenantActorTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/cache/CaffeineCacheDefaultConfigurationTest.java b/application/src/test/java/org/thingsboard/server/cache/CaffeineCacheDefaultConfigurationTest.java index df0bf34929..8253e3021f 100644 --- a/application/src/test/java/org/thingsboard/server/cache/CaffeineCacheDefaultConfigurationTest.java +++ b/application/src/test/java/org/thingsboard/server/cache/CaffeineCacheDefaultConfigurationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java new file mode 100644 index 0000000000..cf94940e07 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java @@ -0,0 +1,1016 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cf; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.action.TbAlarmResult; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.alarm.AlarmInfo; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.alarm.AlarmStatus; +import org.thingsboard.server.common.data.alarm.rule.AlarmRule; +import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; +import org.thingsboard.server.common.data.alarm.rule.condition.DurationAlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.RepeatingAlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NoDataFilterPredicate; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate.NumericOperation; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.StringFilterPredicate; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.StringFilterPredicate.StringOperation; +import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AlarmSchedule; +import org.thingsboard.server.common.data.alarm.rule.condition.schedule.SpecificTimeSchedule; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.CurrentOwnerDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.debug.DebugSettings; +import org.thingsboard.server.common.data.event.CalculatedFieldDebugEvent; +import org.thingsboard.server.common.data.event.EventType; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EventId; +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.event.EventDao; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + +@Slf4j +@DaoSqlTest +public class AlarmRulesTest extends AbstractControllerTest { + + @MockitoSpyBean + private ActorSystemContext actorSystemContext; + + @Autowired + private EventDao eventDao; + + private Device device; + private DeviceId deviceId; + private EntityId originatorId; + private EventId latestEventId; + + @Before + public void beforeEach() throws Exception { + loginSysAdmin(); + + updateDefaultTenantProfileConfig(tenantProfileConfig -> { + tenantProfileConfig.setCfReevaluationCheckInterval(1); + tenantProfileConfig.setAlarmsReevaluationInterval(1); + }); + + loginTenantAdmin(); + device = createDevice("Device A", "aaa"); + deviceId = device.getId(); + originatorId = deviceId; + } + + @Test + public void testCreateAlarm_severityUpdate_clear() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + Map createRules = Map.of( + AlarmSeverity.MAJOR, new Condition("return temperature >= 50;", null, null), + AlarmSeverity.CRITICAL, new Condition("return temperature >= 100;", null, null) + ); + + Condition clearRule = new Condition("return temperature <= 25;", null, null); + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, clearRule); + + postTelemetry(deviceId, "{\"temperature\":50}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + + postTelemetry(deviceId, "{\"temperature\":100}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isSeverityUpdated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + + postTelemetry(deviceId, "{\"temperature\":101}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isUpdated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + + postTelemetry(deviceId, "{\"temperature\":20}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCleared()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK); + }); + } + + @Test + public void testCreateAlarm_simpleConditionExpression() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + SimpleAlarmConditionExpression simpleExpression = new SimpleAlarmConditionExpression(); + AlarmConditionFilter filter = new AlarmConditionFilter(); + filter.setArgument("temperature"); + filter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate predicate = new NumericFilterPredicate(); + predicate.setOperation(NumericOperation.GREATER_OR_EQUAL); + AlarmConditionValue thresholdValue = new AlarmConditionValue<>(); + thresholdValue.setStaticValue(100.0); + predicate.setValue(thresholdValue); + filter.setPredicates(List.of(predicate)); + simpleExpression.setFilters(List.of(filter)); + simpleExpression.setOperation(ComplexOperation.AND); + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition(simpleExpression, null, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + + postTelemetry(deviceId, "{\"temperature\":100}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + @Test + public void testCreateAlarm_eventBeforeDefaultTs() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + + postTelemetry(deviceId, "{\"values\": {\"temperature\": 50}, \"ts\": " + (System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30) + "}")); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + @Test + public void testCreateAlarm_repeatingCondition() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + int eventsCountMajor = 5; + int eventsCountCritical = 10; + Map createRules = Map.of( + AlarmSeverity.MAJOR, new Condition("return temperature >= 50;", eventsCountMajor, null), + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", eventsCountCritical, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + for (int i = 0; i < 4; i++) { + postTelemetry(deviceId, "{\"temperature\":50}"); + Thread.sleep(10); + } + assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + postTelemetry(deviceId, "{\"temperature\":50}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + assertThat(alarmResult.getConditionRepeats()).isEqualTo(5); + }); + + for (int i = 0; i < 4; i++) { + postTelemetry(deviceId, "{\"temperature\":50}"); + Thread.sleep(10); + } + checkAlarmResult(calculatedField, alarmResult -> alarmResult.getConditionRepeats() == 9, alarmResult -> { + assertThat(alarmResult.isUpdated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); + }); + postTelemetry(deviceId, "{\"temperature\":50}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isSeverityUpdated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + assertThat(alarmResult.getConditionRepeats()).isEqualTo(10); + }); + } + + @Test + public void testCreateAlarm_dynamicRepeatingCondition() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + + Argument eventsCountArgument = new Argument(); + eventsCountArgument.setRefEntityKey(new ReferencedEntityKey("eventsCount", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + eventsCountArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument, + "eventsCount", eventsCountArgument + ); + + int eventsCount = 5; + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, + new AlarmConditionValue<>(null, "eventsCount"), null, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"eventsCount\":" + eventsCount + "}"); + for (int i = 0; i < eventsCount; i++) { + postTelemetry(deviceId, "{\"temperature\":50}"); + Thread.sleep(10); + } + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + assertThat(alarmResult.getConditionRepeats()).isEqualTo(eventsCount); + }); + } + + @Test + public void testCreateAlarm_durationCondition() throws Exception { + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("powerConsumption", ArgumentType.TS_LATEST, null)); + argument.setDefaultValue("0"); + Map arguments = Map.of( + "powerConsumption", argument + ); + + long createDurationMs = 5000L; + long clearDurationMs = 3000L; + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return powerConsumption >= 3000;", null, createDurationMs) + ); + Condition clearRule = new Condition("return powerConsumption < 3000;", null, clearDurationMs); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 5 seconds", + arguments, createRules, clearRule); + postTelemetry(deviceId, "{\"powerConsumption\":3500}"); + Thread.sleep(createDurationMs - 2000); + assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + assertThat(alarmResult.getConditionDuration()).isBetween(createDurationMs, createDurationMs + 2000); + }); + + postTelemetry(deviceId, "{\"powerConsumption\":2000}"); + Thread.sleep(clearDurationMs - 2000); + assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCleared()).isTrue(); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK); + assertThat(alarmResult.getConditionDuration()).isBetween(clearDurationMs, clearDurationMs + 2000); + }); + } + + @Test + public void testCreateAlarm_dynamicDurationCondition() throws Exception { + Argument powerConsumptionArgument = new Argument(); + powerConsumptionArgument.setRefEntityKey(new ReferencedEntityKey("powerConsumption", ArgumentType.TS_LATEST, null)); + powerConsumptionArgument.setDefaultValue("0"); + + Argument durationArgument = new Argument(); + durationArgument.setRefEntityKey(new ReferencedEntityKey("duration", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + durationArgument.setDefaultValue("-1"); + Map arguments = Map.of( + "powerConsumption", powerConsumptionArgument, + "duration", durationArgument + ); + + long createDurationMs = 2000L; + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return powerConsumption >= 3000;", null, null, + new AlarmConditionValue(null, "duration"), null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 2 seconds", + arguments, createRules, null); + postTelemetry(deviceId, "{\"powerConsumption\":3500}"); + postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"duration\":" + createDurationMs + "}"); + + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + assertThat(alarmResult.getConditionDuration()).isBetween(createDurationMs, createDurationMs + 2000); + }); + } + + @Test + public void testCreateAlarm_durationCondition_defaultValue() { + Argument powerConsumptionArgument = new Argument(); + powerConsumptionArgument.setRefEntityKey(new ReferencedEntityKey("powerConsumption", ArgumentType.TS_LATEST, null)); + powerConsumptionArgument.setDefaultValue("3500"); + Map arguments = Map.of( + "powerConsumption", powerConsumptionArgument + ); + + long createDurationMs = 2000L; + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return powerConsumption >= 3000;", null, null, + new AlarmConditionValue(2000L, null), null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 2 seconds", + arguments, createRules, null); + + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + assertThat(alarmResult.getConditionDuration()).isBetween(createDurationMs, createDurationMs + 2000); + }); + } + + @Test + public void testCreateAlarm_currentOwnerArgument() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + + Argument temperatureThresholdArgument = new Argument(); + temperatureThresholdArgument.setRefEntityKey(new ReferencedEntityKey("temperatureThreshold", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + temperatureThresholdArgument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); + temperatureThresholdArgument.setDefaultValue("1000"); + + Map arguments = Map.of( + "temperature", temperatureArgument, + "temperatureThreshold", temperatureThresholdArgument + ); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= temperatureThreshold;", null, null) + ); + + device.setCustomerId(customerId); + device = doPost("/api/device", device, Device.class); + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"temperatureThreshold\":50}"); + + postTelemetry(deviceId, "{\"temperature\":51}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + @Test + public void testCreateAndClearAlarm_customerAlarmRule_simpleExpression() throws Exception { + Argument locationArgument = new Argument(); + locationArgument.setRefEntityKey(new ReferencedEntityKey("location", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + locationArgument.setDefaultValue("unknown"); + originatorId = customerId; + + Argument locationFilterArgument = new Argument(); + locationFilterArgument.setRefEntityKey(new ReferencedEntityKey("locationFilter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + locationFilterArgument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); + locationFilterArgument.setDefaultValue("None"); + + Map arguments = Map.of( + "location", locationArgument, + "locationFilter", locationFilterArgument + ); + + Map createRules = Map.of( + AlarmSeverity.INDETERMINATE, new Condition(createSimpleExpression( + "location", StringOperation.CONTAINS, new AlarmConditionValue<>(null, "locationFilter") + ), null, null) + ); + Condition clearRule = new Condition(createSimpleExpression( + "location", StringOperation.NOT_CONTAINS, new AlarmConditionValue<>(null, "locationFilter") + ), null, null); + + CalculatedField calculatedField = createAlarmCf(customerId, "New resident", + arguments, createRules, clearRule); + + loginSysAdmin(); + postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"locationFilter\":\"Kyiv\"}"); + loginTenantAdmin(); + postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"location\":\"Ukraine, Kyiv\"}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.INDETERMINATE); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + + postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"location\":\"Ukraine, Lviv\"}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCleared()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.INDETERMINATE); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK); + }); + } + + @Test + public void testCreateAlarm_dynamicSchedule() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Argument scheduleArgument = new Argument(); + scheduleArgument.setRefEntityKey(new ReferencedEntityKey("schedule", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + scheduleArgument.setDefaultValue("None"); + Map arguments = Map.of( + "temperature", temperatureArgument, + "schedule", scheduleArgument + ); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null, null, + new AlarmConditionValue<>(null, "schedule")) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + String schedule = """ + {"timezone":"Europe/Kiev","items":[{"enabled":false,"dayOfWeek":1,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":2,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":3,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":4,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":5,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":6,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":7,"startsOn":0,"endsOn":0}]} + """; + postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"schedule\":" + schedule + "}"); + postTelemetry(deviceId, "{\"temperature\":50}"); + + Thread.sleep(1000); + assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + + schedule = schedule.replace("\"enabled\":false", "\"enabled\":true"); + postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"schedule\":" + schedule + "}"); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + // checking multiple debug events due to scheduled reevaluation (which also produces debug events) + CalculatedFieldDebugEvent debugEvent = getDebugEvents(calculatedField.getId(), 5).stream() + .filter(event -> event.getResult() != null) + .findFirst().orElse(null); + assertThat(debugEvent).isNotNull(); + TbAlarmResult alarmResult = JacksonUtil.fromString(debugEvent.getResult(), TbAlarmResult.class); + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + @Test + public void testCreateAlarm_noDataPredicate() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + long majorNoDataDuration = 3L; + long criticalNoDataDuration = 10L; + + SimpleAlarmConditionExpression majorExpression = new SimpleAlarmConditionExpression(); + AlarmConditionFilter majorFilter = new AlarmConditionFilter(); + majorFilter.setArgument("temperature"); + majorFilter.setValueType(EntityKeyValueType.NUMERIC); + majorFilter.setPredicates(List.of( + new NumericFilterPredicate(NumericOperation.GREATER, new AlarmConditionValue<>(25.0, null)), + new NoDataFilterPredicate(TimeUnit.SECONDS, new AlarmConditionValue(majorNoDataDuration, null)) + )); + majorExpression.setFilters(List.of(majorFilter)); + + SimpleAlarmConditionExpression criticalExpression = new SimpleAlarmConditionExpression(); + AlarmConditionFilter criticalFilter = new AlarmConditionFilter(); + criticalFilter.setArgument("temperature"); + criticalFilter.setValueType(EntityKeyValueType.NUMERIC); + criticalFilter.setPredicates(List.of( + new NumericFilterPredicate(NumericOperation.GREATER, new AlarmConditionValue<>(25.0, null)), + new NoDataFilterPredicate(TimeUnit.SECONDS, new AlarmConditionValue(criticalNoDataDuration, null)) + )); + criticalExpression.setFilters(List.of(criticalFilter)); + + Map createRules = Map.of( + AlarmSeverity.MAJOR, new Condition(majorExpression, null, null), + AlarmSeverity.CRITICAL, new Condition(criticalExpression, null, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "No Temperature Alarm", + arguments, createRules, null); + + postTelemetry(deviceId, "{\"temperature\":50}"); + + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isSeverityUpdated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + @Test + public void testChangeAlarmType() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + + postTelemetry(deviceId, "{\"temperature\":50}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + + calculatedField.setName("New alarm type"); + calculatedField = saveCalculatedField(calculatedField); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + @Test + public void testChangeRuleExpression() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 100;", null, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + + postTelemetry(deviceId, "{\"temperature\":50}"); + Thread.sleep(1000); + assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + + AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration(); + ((TbelAlarmConditionExpression) configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition().getExpression()) + .setExpression("return temperature >= 50;"); + calculatedField = saveCalculatedField(calculatedField); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + @Test + public void testChangeRequiredEventsCountForRepeatingCondition() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + int eventsCountMajor = 5; + int eventsCountCritical = 10; + Map createRules = Map.of( + AlarmSeverity.MAJOR, new Condition("return temperature >= 50;", eventsCountMajor, null), + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", eventsCountCritical, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + for (int i = 0; i < eventsCountMajor; i++) { + postTelemetry(deviceId, "{\"temperature\":50}"); + Thread.sleep(10); + } + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + assertThat(alarmResult.getConditionRepeats()).isEqualTo(5); + }); + + postTelemetry(deviceId, "{\"temperature\":50}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isUpdated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); + assertThat(alarmResult.getConditionRepeats()).isEqualTo(6); + }); + + // decreasing required events count for critical rule + AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration(); + ((RepeatingAlarmCondition) configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition()) + .setCount(new AlarmConditionValue<>(6, null)); + calculatedField = saveCalculatedField(calculatedField); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isSeverityUpdated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + assertThat(alarmResult.getConditionRepeats()).isEqualTo(6); + }); + } + + @Test + public void testChangeConditionArgumentSource() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + + Argument temperatureThresholdArgument = new Argument(); + temperatureThresholdArgument.setRefEntityKey(new ReferencedEntityKey("temperatureThreshold", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + temperatureThresholdArgument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); + temperatureThresholdArgument.setDefaultValue("100"); + loginSysAdmin(); + postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"temperatureThreshold\":100}"); + loginTenantAdmin(); + postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"temperatureThreshold\":50}"); + + Map arguments = Map.of( + "temperature", temperatureArgument, + "temperatureThreshold", temperatureThresholdArgument + ); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= temperatureThreshold;", null, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + + postTelemetry(deviceId, "{\"temperature\":50}"); + Thread.sleep(1000); + // not created because tenant's threshold 100 is used + assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + + ((AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration()).getArguments().get("temperatureThreshold") + .setRefDynamicSourceConfiguration(null); + // using threshold 50 on device level + calculatedField = saveCalculatedField(calculatedField); + + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + @Test + public void testAlarmDetails() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Argument humidityArgument = new Argument(); + humidityArgument.setRefEntityKey(new ReferencedEntityKey("humidity", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + humidityArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument, + "humidity", humidityArgument + ); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50 && humidity >= 50;", null, null) + ); + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature and Humidity Alarm", + arguments, createRules, null, configuration -> { + configuration.getCreateRules().get(AlarmSeverity.CRITICAL).setAlarmDetails( + "temperature is ${temperature}, humidity is ${humidity}" + ); + }); + + postTelemetry(deviceId, "{\"temperature\":50}"); + postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"humidity\":50}"); + + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getDetails().get("data").asText()) + .isEqualTo("temperature is 50, humidity is 50"); + }); + + ((AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration()).getCreateRules().get(AlarmSeverity.CRITICAL).setAlarmDetails( + "UPDATED temperature is ${temperature}, humidity is ${humidity}" + ); + calculatedField = saveCalculatedField(calculatedField); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isFalse(); + assertThat(alarmResult.isUpdated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getDetails().get("data").asText()) + .isEqualTo("UPDATED temperature is 50, humidity is 50"); + }); + } + + @Test + public void testCreateAlarm_scheduleStarted() throws Exception { + Argument parkingSpotOccupiedArgument = new Argument(); + parkingSpotOccupiedArgument.setRefEntityKey(new ReferencedEntityKey("parkingSpotOccupied", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + parkingSpotOccupiedArgument.setDefaultValue("false"); + Map arguments = Map.of( + "parkingSpotOccupied", parkingSpotOccupiedArgument + ); + + SpecificTimeSchedule schedule = new SpecificTimeSchedule(); + schedule.setTimezone(ZoneId.systemDefault().getId()); + schedule.setDaysOfWeek(Set.of(1, 2, 3, 4, 5, 6, 7)); + long startsOn = Duration.between(LocalDate.now().atStartOfDay(), LocalDateTime.now()) + .plus(15, ChronoUnit.SECONDS).toMillis(); + schedule.setStartsOn(startsOn); + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return parkingSpotOccupied == true;", null, null, null, + new AlarmConditionValue<>(schedule, null)) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "Illegal parking alarm", + arguments, createRules, null); + + postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"parkingSpotOccupied\":true}"); + + Thread.sleep(10000); + assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + CalculatedFieldDebugEvent debugEvent = getDebugEvents(calculatedField.getId(), 5).stream() + .filter(event -> event.getResult() != null) + .findFirst().orElse(null); + assertThat(debugEvent).isNotNull(); + TbAlarmResult alarmResult = JacksonUtil.fromString(debugEvent.getResult(), TbAlarmResult.class); + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + @Test + public void testManualClearAlarm() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + + postTelemetry(deviceId, "{\"temperature\":50}"); + Alarm alarm = checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }).getAlarm(); + + doPost("/api/alarm/" + alarm.getId() + "/clear", AlarmInfo.class); + Thread.sleep(1000); + postTelemetry(deviceId, "{\"temperature\":50}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.getAlarm().getId()).isNotEqualTo(alarm.getId()); + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + + // TODO: MSA tests + + private TbAlarmResult checkAlarmResult(CalculatedField calculatedField, Consumer assertion) { + return checkAlarmResult(calculatedField, null, assertion); + } + + private TbAlarmResult checkAlarmResult(CalculatedField calculatedField, + Predicate waitFor, + Consumer assertion) { + TbAlarmResult alarmResult = await().atMost(TIMEOUT, TimeUnit.SECONDS) + .until(() -> getLatestAlarmResult(calculatedField.getId()), result -> + result != null && (waitFor == null || waitFor.test(result))); + assertion.accept(alarmResult); + + Alarm alarm = alarmResult.getAlarm(); + assertThat(alarm.getOriginator()).isEqualTo(originatorId); + assertThat(alarm.getType()).isEqualTo(calculatedField.getName()); + return alarmResult; + } + + private TbAlarmResult getLatestAlarmResult(CalculatedFieldId calculatedFieldId) { + List debugEvents = getDebugEvents(calculatedFieldId, 1); + if (debugEvents.isEmpty()) { + return null; + } + CalculatedFieldDebugEvent debugEvent = debugEvents.get(0); + if (debugEvent.getError() != null) { + throw new RuntimeException(debugEvent.getError()); + } + if (debugEvent.getId().equals(latestEventId)) { + return null; + } + latestEventId = debugEvent.getId(); + return JacksonUtil.fromString(debugEvent.getResult(), TbAlarmResult.class); + } + + private CalculatedField createAlarmCf(EntityId entityId, + String alarmType, + Map arguments, + Map createConditions, + Condition clearCondition, + Consumer... modifier) { + Map createRules = new HashMap<>(); + createConditions.forEach((severity, condition) -> { + createRules.put(severity, toAlarmRule(condition)); + }); + AlarmRule clearRule = clearCondition != null ? toAlarmRule(clearCondition) : null; + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(entityId); + calculatedField.setName(alarmType); + calculatedField.setType(CalculatedFieldType.ALARM); + AlarmCalculatedFieldConfiguration configuration = new AlarmCalculatedFieldConfiguration(); + configuration.setArguments(arguments); + configuration.setCreateRules(createRules); + configuration.setClearRule(clearRule); + calculatedField.setConfiguration(configuration); + calculatedField.setDebugSettings(DebugSettings.all()); + if (modifier.length > 0) { + modifier[0].accept(configuration); + } + CalculatedField savedCalculatedField = saveCalculatedField(calculatedField); + + CalculatedFieldDebugEvent debugEvent = await().atMost(TIMEOUT, TimeUnit.SECONDS) + .until(() -> getDebugEvents(savedCalculatedField.getId(), 1), + events -> !events.isEmpty()).get(0); + latestEventId = debugEvent.getId(); + return savedCalculatedField; + } + + private AlarmRule toAlarmRule(Condition condition) { + AlarmRule rule = new AlarmRule(); + AlarmConditionExpression expression; + if (condition.getTbelExpression() != null) { + TbelAlarmConditionExpression tbelExpression = new TbelAlarmConditionExpression(); + tbelExpression.setExpression(condition.getTbelExpression()); + expression = tbelExpression; + } else { + expression = condition.getSimpleExpression(); + } + if (condition.getEventsCount() != null) { + RepeatingAlarmCondition alarmCondition = new RepeatingAlarmCondition(); + alarmCondition.setExpression(expression); + alarmCondition.setCount(condition.getEventsCount()); + rule.setCondition(alarmCondition); + } else if (condition.getDuration() != null) { + DurationAlarmCondition alarmCondition = new DurationAlarmCondition(); + alarmCondition.setExpression(expression); + alarmCondition.setUnit(TimeUnit.MILLISECONDS); + alarmCondition.setValue(condition.getDuration()); + rule.setCondition(alarmCondition); + } else { + SimpleAlarmCondition alarmCondition = new SimpleAlarmCondition(); + alarmCondition.setExpression(expression); + rule.setCondition(alarmCondition); + } + if (condition.getSchedule() != null) { + rule.getCondition().setSchedule(condition.getSchedule()); + } + return rule; + } + + private SimpleAlarmConditionExpression createSimpleExpression(String argument, StringOperation stringOperation, AlarmConditionValue conditionValue) { + SimpleAlarmConditionExpression simpleExpression = new SimpleAlarmConditionExpression(); + AlarmConditionFilter filter = new AlarmConditionFilter(); + filter.setArgument(argument); + filter.setValueType(EntityKeyValueType.STRING); + StringFilterPredicate predicate = new StringFilterPredicate(); + predicate.setOperation(stringOperation); + predicate.setValue(conditionValue); + filter.setPredicates(List.of(predicate)); + simpleExpression.setFilters(List.of(filter)); + return simpleExpression; + } + + private List getDebugEvents(CalculatedFieldId calculatedFieldId, int limit) { + return eventDao.findLatestEvents(tenantId.getId(), calculatedFieldId.getId(), EventType.DEBUG_CALCULATED_FIELD, limit).stream() + .map(e -> (CalculatedFieldDebugEvent) e).toList(); + } + + @Getter + @AllArgsConstructor + private static final class Condition { + + private final String tbelExpression; + private final SimpleAlarmConditionExpression simpleExpression; + private AlarmConditionValue eventsCount; + private AlarmConditionValue duration; + private AlarmConditionValue schedule; + + private Condition(String tbelExpression, Integer eventsCount, Long durationMs) { + this.tbelExpression = tbelExpression; + this.simpleExpression = null; + if (eventsCount != null) { + this.eventsCount = new AlarmConditionValue<>(eventsCount, null); + } + if (durationMs != null) { + this.duration = new AlarmConditionValue<>(durationMs, null); + } + } + + private Condition(SimpleAlarmConditionExpression simpleExpression, Integer eventsCount, Long durationMs) { + this.tbelExpression = null; + this.simpleExpression = simpleExpression; + if (eventsCount != null) { + this.eventsCount = new AlarmConditionValue<>(eventsCount, null); + } + if (durationMs != null) { + this.duration = new AlarmConditionValue<>(durationMs, null); + } + } + + } + +} diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldCurrentOwnerTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldCurrentOwnerTest.java new file mode 100644 index 0000000000..52180a888d --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldCurrentOwnerTest.java @@ -0,0 +1,198 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cf; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Test; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.CurrentOwnerDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class CalculatedFieldCurrentOwnerTest extends AbstractControllerTest { + + public static final int TIMEOUT = 60; + public static final int POLL_INTERVAL = 1; + + @Test + public void testCreateCFWithCurrentOwner() throws Exception { + loginTenantAdmin(); + + postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":5}"); + + Device testDevice = createDevice("Test device", "1234567890"); + + doPost("/api/customer/" + customerId.getId() + "/device/" + testDevice.getId().getId()).andExpect(status().isOk()); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", buildCalculatedField(testDevice.getId()), CalculatedField.class); + + await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("105"); + }); + + postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":10}"); + + await().alias("update telemetry -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("110"); + }); + } + + @Test + public void testChangeOwner() throws Exception { + loginSysAdmin(); + + postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":50}"); + + loginTenantAdmin(); + + postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":5}"); + Device testDevice = createDevice("Test device", "1234567890"); + doPost("/api/customer/" + customerId.getId() + "/device/" + testDevice.getId().getId()).andExpect(status().isOk()); + + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", buildCalculatedField(testDevice.getId()), CalculatedField.class); + + await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("105"); + }); + + doDelete("/api/customer/device/" + testDevice.getId().getId()).andExpect(status().isOk()); + + await().alias("change owner -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("150"); + }); + } + + @Test + public void testCreateCFWithCurrentOwnerWhenEntityIsProfile() throws Exception { + loginSysAdmin(); + + postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":50}"); + + loginTenantAdmin(); + + postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":5}"); + + AssetProfile assetProfile = doPost("/api/assetProfile", createAssetProfile("Test Asset Profile"), AssetProfile.class); + + Asset asset1 = createAsset("Test asset 1", assetProfile.getId()); + doPost("/api/customer/" + customerId.getId() + "/asset/" + asset1.getId().getId()).andExpect(status().isOk()); + + Asset asset2 = createAsset("Test asset 2", assetProfile.getId()); // owner - TENANT + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", buildCalculatedField(assetProfile.getId()), CalculatedField.class); + + await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + // result of asset 1 + ObjectNode result1 = getLatestTelemetry(asset1.getId(), "result"); + assertThat(result1).isNotNull(); + assertThat(result1.get("result").get(0).get("value").asText()).isEqualTo("105"); + + // result of asset 2 + ObjectNode result2 = getLatestTelemetry(asset2.getId(), "result"); + assertThat(result2).isNotNull(); + assertThat(result2.get("result").get(0).get("value").asText()).isEqualTo("150"); + }); + + doPost("/api/customer/" + customerId.getId() + "/asset/" + asset2.getId().getId()).andExpect(status().isOk()); + + await().alias("change asset2 owner -> recalculate state for asset 2").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + // result of asset 2 + ObjectNode result2 = getLatestTelemetry(asset2.getId(), "result"); + assertThat(result2).isNotNull(); + assertThat(result2.get("result").get(0).get("value").asText()).isEqualTo("105"); + }); + } + + private CalculatedField buildCalculatedField(EntityId entityId) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(entityId); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("Test Calculated Field"); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("attrKey", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE); + argument.setRefEntityKey(refEntityKey); + argument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); + + config.setArguments(Map.of("a", argument)); + + config.setExpression("a + 100"); + + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setName("result"); + output.setDecimalsByDefault(0); + + config.setOutput(output); + + calculatedField.setConfiguration(config); + return calculatedField; + } + + private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { + return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); + } + + private Asset createAsset(String name, AssetProfileId assetProfileId) { + Asset asset = new Asset(); + asset.setName(name); + asset.setAssetProfileId(assetProfileId); + return doPost("/api/asset", asset, Asset.class); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index c83e8c5ee3..129d81b400 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,37 +15,54 @@ */ package org.thingsboard.server.cf; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Test; -import org.junit.jupiter.api.BeforeEach; -import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AttributeScope; -import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityInfo; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; -import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.AttributesOutput; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesImmediateOutputStrategy; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; +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.common.data.relation.RelationPathLevel; import org.thingsboard.server.controller.CalculatedFieldControllerTest; import org.thingsboard.server.dao.service.DaoSqlTest; +import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; 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 { @@ -53,16 +70,11 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes public static final int TIMEOUT = 60; public static final int POLL_INTERVAL = 1; - @BeforeEach - void setUp() throws Exception { - loginTenantAdmin(); - } - @Test public void testSimpleCalculatedFieldWhenAllTelemetryPresent() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":25}")); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"deviceTemperature\":40}")); + postTelemetry(testDevice.getId(), "{\"temperature\":25}"); + postAttributes(testDevice.getId(), AttributeScope.SERVER_SCOPE, "{\"deviceTemperature\":40}"); CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(testDevice.getId()); @@ -80,13 +92,11 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("T", argument)); config.setExpression("(T * 9/5) + 32"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("fahrenheitTemp"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); calculatedField.setConfiguration(config); - calculatedField.setVersion(1L); CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); @@ -98,7 +108,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0"); }); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":30}")); + postTelemetry(testDevice.getId(), "{\"temperature\":30}"); await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -108,10 +118,12 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("86.0"); }); - Output savedOutput = savedCalculatedField.getConfiguration().getOutput(); - savedOutput.setType(OutputType.ATTRIBUTES); - savedOutput.setScope(AttributeScope.SERVER_SCOPE); - savedOutput.setName("temperatureF"); + AttributesOutput newOutput = new AttributesOutput(); + newOutput.setScope(AttributeScope.SERVER_SCOPE); + newOutput.setName("temperatureF"); + config.setOutput(newOutput); + savedCalculatedField.setConfiguration(config); + savedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); await().alias("update CF output -> perform calculation with updated output").atMost(TIMEOUT, TimeUnit.SECONDS) @@ -119,10 +131,11 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes .untilAsserted(() -> { ArrayNode temperatureF = getServerAttributes(testDevice.getId(), "temperatureF"); assertThat(temperatureF).isNotNull(); + assertThat(temperatureF.get(0)).isNotNull(); assertThat(temperatureF.get(0).get("value").asText()).isEqualTo("86.0"); }); - 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); @@ -134,7 +147,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) @@ -165,9 +178,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("T", argument)); config.setExpression("(T * 9/5) + 32"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("fahrenheitTemp"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); calculatedField.setConfiguration(config); @@ -183,7 +195,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").isNull()).isTrue(); }); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":30}")); + postTelemetry(testDevice.getId(), "{\"temperature\":30}"); await().alias("update telemetry -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -214,9 +226,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("T", argument)); config.setExpression("(T * 9/5) + 32"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("fahrenheitTemp"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); calculatedField.setConfiguration(config); @@ -232,7 +243,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("53.6"); }); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":30}")); + postTelemetry(testDevice.getId(), "{\"temperature\":30}"); await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -246,15 +257,15 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes @Test public void testSimpleCalculatedFieldWhenEntityIdIsProfile() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"x\":40}")); + postAttributes(testDevice.getId(), AttributeScope.SERVER_SCOPE, "{\"x\":40}"); AssetProfile assetProfile = doPost("/api/assetProfile", createAssetProfile("Test Asset Profile"), AssetProfile.class); Asset asset1 = createAsset("Test asset 1", assetProfile.getId()); - doPost("/api/plugins/telemetry/ASSET/" + asset1.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":11}")); + postAttributes(asset1.getId(), AttributeScope.SERVER_SCOPE, "{\"y\":11}"); Asset asset2 = createAsset("Test asset 2", assetProfile.getId()); - doPost("/api/plugins/telemetry/ASSET/" + asset2.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":12}")); + postAttributes(asset2.getId(), AttributeScope.SERVER_SCOPE, "{\"y\":12}"); CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(assetProfile.getId()); @@ -278,9 +289,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setExpression("x + y"); - Output output = new Output(); + AttributesOutput output = new AttributesOutput(); output.setName("z"); - output.setType(OutputType.ATTRIBUTES); output.setScope(AttributeScope.SERVER_SCOPE); config.setOutput(output); @@ -304,7 +314,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(z2.get(0).get("value").asText()).isEqualTo("52.0"); }); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"x\":25}")); + postAttributes(testDevice.getId(), AttributeScope.SERVER_SCOPE, "{\"x\":25}"); await().alias("update device telemetry -> recalculate state for all assets").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -320,7 +330,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(z2.get(0).get("value").asText()).isEqualTo("37.0"); }); - doPost("/api/plugins/telemetry/ASSET/" + asset1.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":15}")); + postAttributes(asset1.getId(), AttributeScope.SERVER_SCOPE, "{\"y\":15}"); await().alias("update asset 1 telemetry -> recalculate state only for asset 1").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -336,7 +346,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(z2.get(0).get("value").asText()).isEqualTo("37.0"); }); - doPost("/api/plugins/telemetry/ASSET/" + asset2.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":5}")); + postAttributes(asset2.getId(), AttributeScope.SERVER_SCOPE, "{\"y\":5}"); await().alias("update asset 2 telemetry -> recalculate state only for asset 2").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -353,7 +363,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes }); Asset asset3 = createAsset("Test asset 3", assetProfile.getId()); - doPost("/api/plugins/telemetry/ASSET/" + asset3.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"y\":13}")); + postAttributes(asset3.getId(), AttributeScope.SERVER_SCOPE, "{\"y\":13}"); Asset finalAsset3 = asset3; await().alias("add new entity to profile -> calculate state for new entity").atMost(TIMEOUT, TimeUnit.SECONDS) @@ -365,7 +375,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(z3.get(0).get("value").asText()).isEqualTo("38.0"); }); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"x\":20}")); + postAttributes(testDevice.getId(), AttributeScope.SERVER_SCOPE, "{\"x\":20}"); await().alias("update device telemetry -> recalculate state for all assets").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -391,7 +401,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes asset3.setAssetProfileId(newAssetProfile.getId()); asset3 = doPost("/api/asset", asset3, Asset.class); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"x\":15}")); + postAttributes(testDevice.getId(), AttributeScope.SERVER_SCOPE, "{\"x\":15}"); Asset updatedAsset3 = asset3; await().alias("update device telemetry -> recalculate state for asset 1 and asset 2").atMost(TIMEOUT, TimeUnit.SECONDS) @@ -417,7 +427,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes @Test public void testSimpleCalculatedFieldWhenExpressionIsInvalid() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":25}")); + postTelemetry(testDevice.getId(), "{\"temperature\":25}"); CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(testDevice.getId()); @@ -435,9 +445,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("T", argument)); config.setExpression("(T * 9/0) + 32"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("fahrenheitTemp"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); calculatedField.setConfiguration(config); @@ -453,7 +462,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").isNull()).isTrue(); }); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"temperature\":30}")); + postTelemetry(testDevice.getId(), "{\"temperature\":30}"); await().alias("update telemetry -> ctx is not initialized -> no calculation perform").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -468,7 +477,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes public void testSimpleCalculatedFieldWhenUseLatestTsIsTrue() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); long ts = System.currentTimeMillis() - 300000L; - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts))); + postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts)); CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(testDevice.getId()); @@ -485,9 +494,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("T", argument)); config.setExpression("(T * 9/5) + 32"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("fahrenheitTemp"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); config.setUseLatestTs(true); @@ -512,10 +520,10 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes long ts = System.currentTimeMillis(); long tsA = ts - 300000L; - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"a\":1}}", tsA))); + postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"a\":1}}", tsA)); long tsB = ts - 300L; - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"b\":5}}", tsB))); + postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"b\":5}}", tsB)); CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(testDevice.getId()); @@ -535,9 +543,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("a", argument1, "b", argument2)); config.setExpression("a + b"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("c"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); config.setUseLatestTs(true); @@ -556,7 +563,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes }); long tsABeforeTsB = tsB - 300L; - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"a\":10}}", tsABeforeTsB))); + postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"a\":10}}", tsABeforeTsB)); await().alias("update telemetry with ts less than latest -> save result with latest ts").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -573,7 +580,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes Device testDevice = createDevice("Test device", "1234567890"); long ts = System.currentTimeMillis() - 300000L; - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts))); + postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts)); CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(testDevice.getId()); @@ -590,9 +597,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("T", argument)); config.setExpression("return {\"ts\": ctx.latestTs, \"values\": {\"fahrenheitTemp\": (T * 1.8) + 32}};"); - Output output = new Output(); - output.setType(OutputType.TIME_SERIES); - config.setOutput(output); + config.setOutput(new TimeSeriesOutput()); calculatedField.setConfiguration(config); @@ -636,9 +641,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("a", argument1, "b", argument2, "c", argument3)); config.setExpression("a + b + c"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("d"); - output.setType(OutputType.TIME_SERIES); output.setDecimalsByDefault(0); config.setOutput(output); @@ -656,7 +660,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(d.get("d").get(0).get("value").asText()).isEqualTo("600"); }); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"a\":10}")); + postTelemetry(testDevice.getId(), "{\"a\":10}"); await().alias("update telemetry -> save result with ts of 'a' argument").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -668,8 +672,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(keys.get("d").get(0).get("value").asText()).isEqualTo("510"); }); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"b\":20}")); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"c\":30}")); + postTelemetry(testDevice.getId(), "{\"b\":20}"); + postTelemetry(testDevice.getId(), "{\"c\":30}"); await().alias("update telemetry -> save result with latest ts of updated arguments").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -711,9 +715,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("m", argument)); config.setExpression("m + 1"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("m1"); - output.setType(OutputType.TIME_SERIES); output.setDecimalsByDefault(0); config.setOutput(output); @@ -721,7 +724,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"m\":1}")); + postTelemetry(testDevice.getId(), "{\"m\":1}"); await().alias("create CF -> ctx is initialized -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -735,7 +738,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes calculatedField.setConfiguration(config); calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"m\":2}")); + postTelemetry(testDevice.getId(), "{\"m\":2}"); await().alias("update CF -> ctx is not initialized -> no calculation performed").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -746,10 +749,483 @@ 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]]"; + + postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, "{\"allowedZone\":" + allowedPolygon + "}"); + postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, "{\"restrictedZone\":" + restrictedPolygon + "}"); + + // Initial device coordinates (inside Allowed, outside Restricted) + postTelemetry(device.getId(), "{\"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 + AttributesOutput out = new AttributesOutput(); + out.setScope(AttributeScope.SERVER_SCOPE); + cfg.setOutput(out); + + cf.setConfiguration(cfg); + + doPost("/api/calculatedField", cf, CalculatedField.class); + + // --- Assert initial evaluation --- + 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 transition events as no transitions happened yet + assertThat(attrs).isNotNull().isNotEmpty().hasSize(2); + Map m = kv(attrs); + assertThat(m).containsEntry("allowedZonesStatus", "INSIDE") + .containsEntry("restrictedZonesStatus", "OUTSIDE"); + }); + + // --- delete attributes reported in previous evaluation + doDelete("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/SERVER_SCOPE?keys=allowedZonesStatus,restrictedZonesStatus", String.class); + + // --- Update restrictedZone by 'restrictedZone' attribute update + postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, "{\"restrictedZone\":" + restrictedPolygon + "}"); + + // --- 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); + postAttributes(allowedZoneAsset.getId(), AttributeScope.SERVER_SCOPE, "{\"zone\":" + allowedPolygon + "}"); + + Asset restrictedZoneAsset = createAsset("Restricted Zone", null); + postAttributes(restrictedZoneAsset.getId(), AttributeScope.SERVER_SCOPE, "{\"zone\":" + restrictedPolygon + "}"); + + // Relations from device to zones + createEntityRelation(device.getId(), allowedZoneAsset.getId(), "AllowedZone"); + createEntityRelation(device.getId(), restrictedZoneAsset.getId(), "RestrictedZone"); + + // Initial device coordinates (inside Allowed, outside Restricted) + postTelemetry(device.getId(), "{\"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 RelationPathQueryDynamicSourceConfiguration(); + allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "AllowedZone"))); + allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration); + + ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + var restrictedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + restrictedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "RestrictedZone"))); + restrictedZonesGroup.setRefDynamicSourceConfiguration(restrictedZoneDynamicSourceConfiguration); + + cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup)); + + // Output to server attributes + AttributesOutput out = new AttributesOutput(); + out.setScope(AttributeScope.SERVER_SCOPE); + cfg.setOutput(out); + + cf.setConfiguration(cfg); + + doPost("/api/calculatedField", cf, CalculatedField.class); + + // --- Assert initial evaluation --- + await().alias("initial geofencing evaluation") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs = getServerAttributes(device.getId(), + "allowedZonesEvent", "allowedZonesStatus", "restrictedZonesStatus", "restrictedZonesEvent"); + assertThat(attrs).isNotNull().isNotEmpty().hasSize(2); + Map m = kv(attrs); + assertThat(m).containsEntry("allowedZonesStatus", "INSIDE") + .containsEntry("restrictedZonesStatus", "OUTSIDE"); + }); + + // --- Move the device into Restricted zone (and outside Allowed) --- + postTelemetry(device.getId(), "{\"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(); + int minAllowedScheduledUpdateIntervalInSecForCF = TIMEOUT / 10; + foundTenantProfile.getDefaultProfileConfiguration().setMinAllowedScheduledUpdateIntervalInSecForCF(minAllowedScheduledUpdateIntervalInSecForCF); + TenantProfile savedTenantProfile = doPost("/api/tenantProfile", foundTenantProfile, TenantProfile.class); + assertThat(savedTenantProfile).isNotNull(); + assertThat(savedTenantProfile.getDefaultProfileConfiguration().getMinAllowedScheduledUpdateIntervalInSecForCF()).isEqualTo(minAllowedScheduledUpdateIntervalInSecForCF); + 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); + postAttributes(allowedZoneA.getId(), AttributeScope.SERVER_SCOPE, "{\"zone\":" + allowedPolygonA + "}"); + + // Relation from device to Allowed Zone A + createEntityRelation(device.getId(), allowedZoneA.getId(), "AllowedZone"); + + // Initial device coordinates: INSIDE Zone A + postTelemetry(device.getId(), "{\"latitude\":50.4730,\"longitude\":30.5050}"); + + // --- 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 RelationPathQueryDynamicSourceConfiguration(); + allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "AllowedZone"))); + allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration); + cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup)); + + // Server attributes output + AttributesOutput out = new AttributesOutput(); + out.setScope(AttributeScope.SERVER_SCOPE); + cfg.setOutput(out); + + // Enable scheduled refresh with a 6-second interval + cfg.setScheduledUpdateInterval(minAllowedScheduledUpdateIntervalInSecForCF); + cfg.setScheduledUpdateEnabled(true); + + 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 --- + 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(1); + Map m = kv(attrs); + assertThat(m).containsEntry("allowedZonesStatus", "INSIDE"); + }); + + // --- Move device OUTSIDE Zone A (expect LEFT) --- + postTelemetry(device.getId(), "{\"latitude\":50.4760,\"longitude\":30.5110}"); + + 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); + postAttributes(allowedZoneB.getId(), AttributeScope.SERVER_SCOPE, "{\"zone\":" + allowedPolygonB + "}"); + + // Add a new relation + createEntityRelation(device.getId(), allowedZoneB.getId(), "AllowedZone"); + + awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsReadyToRefreshDynamicArguments(device.getId(), savedCalculatedField.getId(), minAllowedScheduledUpdateIntervalInSecForCF); + + // --- Same coordinates as before, but now we expect INSIDE group status since a new zone is registered --- + postTelemetry(device.getId(), "{\"latitude\":50.4760,\"longitude\":30.5110}"); + + // --- Assert dynamic refresh picks up a new relation and flips status back to INSIDE 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", "LEFT") // attribute from previous eval with outdated ts. + .containsEntry("allowedZonesStatus", "INSIDE"); + }); + } + + @Test + public void testPropagationCalculatedField_withExpression() throws Exception { + // --- Arrange entities --- + Device device = createDevice("Propagation Device With Expression", "sn-prop-1"); + Asset asset1 = createAsset("Propagated Asset 1", null); + Asset asset2 = createAsset("Propagated Asset 2", null); + + // Create relations FROM assets TO device + createEntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + createEntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + + // Telemetry on device + postTelemetry(device.getId(), "{\"temperature\":12.5, \"humidity\":85}"); + + // --- Build CF: PROPAGATION with expression --- + CalculatedField cf = new CalculatedField(); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.PROPAGATION); + cf.setName("Propagation CF (expr)"); + cf.setConfigurationVersion(1); + + PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.TO, EntityRelation.CONTAINS_TYPE)); + cfg.setApplyExpressionToResolvedArguments(true); + + Argument arg1 = new Argument(); + arg1.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + + Argument arg2 = new Argument(); + arg2.setRefEntityKey(new ReferencedEntityKey("humidity", ArgumentType.TS_LATEST, null)); + + cfg.setArguments(Map.of("t", arg1, "h", arg2)); + cfg.setExpression("return { testResult: (t + h) / 2};"); + + AttributesOutput output = new AttributesOutput(); + output.setScope(AttributeScope.SERVER_SCOPE); + cfg.setOutput(output); + + cf.setConfiguration(cfg); + + doPost("/api/calculatedField", cf, CalculatedField.class); + + // --- Assert propagated calculation (expression applied) --- + await().alias("propagation expr mode evaluation") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs1 = getServerAttributes(asset1.getId(), "testResult"); + ArrayNode attrs2 = getServerAttributes(asset2.getId(), "testResult"); + assertThat(attrs1).isNotNull(); + assertThat(attrs2).isNotNull(); + assertThat(attrs1.get(0).get("value").asDouble()).isEqualTo(48.75); + assertThat(attrs2.get(0).get("value").asDouble()).isEqualTo(48.75); + }); + + String deleteUrl = String.format("/api/v2/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + asset1.getId().getId(), EntityType.ASSET, + EntityRelation.CONTAINS_TYPE, device.getId().getId(), EntityType.DEVICE + ); + doDelete(deleteUrl).andExpect(status().isOk()); + doDelete("/api/plugins/telemetry/ASSET/" + asset1.getId() + "/SERVER_SCOPE?keys=testResult").andExpect(status().isOk()); + + postTelemetry(device.getId(), "{\"temperature\":25}"); + + // --- Assert propagated calculation (expression applied with new temperature argument and one relation removed) --- + await().alias("propagation expr mode evaluation after temperature update") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs1 = getServerAttributes(asset1.getId(), "testResult"); + ArrayNode attrs2 = getServerAttributes(asset2.getId(), "testResult"); + assertThat(attrs1).isNullOrEmpty(); + assertThat(attrs2).isNotNull(); + assertThat(attrs2.get(0).get("value").asDouble()).isEqualTo(55); + }); + } + + @Test + public void testPropagationCalculatedField_withoutExpression() throws Exception { + // --- Arrange entities --- + Device device = createDevice("Propagation Device Without Expression", "sn-prop-2"); + Asset asset1 = createAsset("Propagated Asset 1", null); + Asset asset2 = createAsset("Propagated Asset 2", null); + + // Create relations FROM assets TO device + createEntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + createEntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + + // Telemetry on device + long ts = System.currentTimeMillis() - 300000L; + postTelemetry(device.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":12.5, \"humidity\":85}}", ts)); + + // --- Build CF: PROPAGATION without expression --- + CalculatedField cf = new CalculatedField(); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.PROPAGATION); + cf.setName("Propagation CF (args-only)"); + cf.setConfigurationVersion(1); + + PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.TO, EntityRelation.CONTAINS_TYPE)); + cfg.setApplyExpressionToResolvedArguments(false); // arguments-only mode + + Argument arg1 = new Argument(); + arg1.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + Argument arg2 = new Argument(); + arg2.setRefEntityKey(new ReferencedEntityKey("humidity", ArgumentType.TS_LATEST, null)); + + cfg.setArguments(Map.of("temperatureComputed", arg1, "humidityComputed", arg2)); + + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setStrategy(new TimeSeriesImmediateOutputStrategy(0, true, true, true, true)); + cfg.setOutput(output); + + cf.setConfiguration(cfg); + + doPost("/api/calculatedField", cf, CalculatedField.class); + + // --- Assert propagated calculation (arguments-only mode) --- + await().alias("propagation args-only evaluation") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode telemetry1 = getLatestTelemetry(asset1.getId(), "temperatureComputed,humidityComputed"); + ObjectNode telemetry2 = getLatestTelemetry(asset2.getId(), "temperatureComputed,humidityComputed"); + assertThat(telemetry1).isNotNull(); + assertThat(telemetry2).isNotNull(); + assertThat(telemetry1.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(telemetry1.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(12.5); + assertThat(telemetry1.get("humidityComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(telemetry1.get("humidityComputed").get(0).get("value").asDouble()).isEqualTo(85); + assertThat(telemetry2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(telemetry2.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(12.5); + assertThat(telemetry2.get("humidityComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(telemetry2.get("humidityComputed").get(0).get("value").asDouble()).isEqualTo(85); + }); + + String deleteUrl = String.format("/api/v2/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + asset1.getId().getId(), EntityType.ASSET, + EntityRelation.CONTAINS_TYPE, device.getId().getId(), EntityType.DEVICE + ); + doDelete(deleteUrl).andExpect(status().isOk()); + doDelete("/api/plugins/telemetry/ASSET/" + asset1.getId() + "/timeseries/delete?keys=temperatureComputed,humidityComputed&deleteAllDataForKeys=true").andExpect(status().isOk()); + + // Update telemetry on the device + long newTs = ts + 300000L; + postTelemetry(device.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":25}}", newTs)); + + // --- Assert propagated calculation (arguments-only mode after update) --- + await().alias("propagation args-only evaluation after temperature update") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode telemetry1 = getLatestTelemetry(asset1.getId(), "temperatureComputed,humidityComputed"); + ObjectNode telemetry2 = getLatestTelemetry(asset2.getId(), "temperatureComputed,humidityComputed"); + assertThat(telemetry1).isNotNull(); + assertThat(telemetry2).isNotNull(); + assertThat(telemetry1.get("temperatureComputed").get(0).get("value")).isEqualTo(NullNode.instance); + assertThat(telemetry1.get("humidityComputed").get(0).get("value")).isEqualTo(NullNode.instance); + + assertThat(telemetry2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs)); + assertThat(telemetry2.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(25); + // TS for humidity is not updated -> expected + assertThat(telemetry2.get("humidityComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(telemetry2.get("humidityComputed").get(0).get("value").asDouble()).isEqualTo(85); + }); + + Asset asset3 = createAsset("Propagated Asset 3", null); + createEntityRelation(asset3.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + + // --- Assert propagated calculation (arguments-only mode after update) --- + await().alias("propagation args-only to new entity after relation creation") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode telemetry = getLatestTelemetry(asset3.getId(), "temperatureComputed,humidityComputed"); + assertThat(telemetry).isNotNull(); + assertThat(telemetry.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs)); + assertThat(telemetry.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(25); + assertThat(telemetry.get("humidityComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs)); + assertThat(telemetry.get("humidityComputed").get(0).get("value").asDouble()).isEqualTo(85); + }); + + } + @Test public void testCalculatedFieldWhenTheSameTelemetryKeysUsed() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"a\":5}")); + postTelemetry(testDevice.getId(), "{\"a\":5}"); CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(testDevice.getId()); @@ -767,9 +1243,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes config.setArguments(Map.of("a", argumentA, "b", argumentB)); config.setExpression("a + b"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("c"); - output.setType(OutputType.TIME_SERIES); output.setDecimalsByDefault(0); config.setOutput(output); @@ -785,7 +1260,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes assertThat(c.get("c").get(0).get("value").asText()).isEqualTo("10"); }); - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"a\":10}")); + postTelemetry(testDevice.getId(), "{\"a\":10}"); await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -796,6 +1271,125 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes }); } + @Test + public void testCalculatedFieldWhenBatchOfTelemetrySent() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + long now = System.currentTimeMillis(); + postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"a\":5, \"b\":10}}", now - TimeUnit.MINUTES.toMillis(3))); + postTelemetry(testDevice.getId(), String.format("{\"ts\": %s, \"values\": {\"b\":20}}", now - TimeUnit.MINUTES.toMillis(1))); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SCRIPT); + calculatedField.setName("Script CF"); + calculatedField.setDebugSettings(DebugSettings.all()); + + ScriptCalculatedFieldConfiguration config = new ScriptCalculatedFieldConfiguration(); + + ReferencedEntityKey refEntityKeyA = new ReferencedEntityKey("a", ArgumentType.TS_LATEST, null); + Argument argumentA = new Argument(); + argumentA.setRefEntityKey(refEntityKeyA); + Argument argumentB = new Argument(); + ReferencedEntityKey refEntityKeyB = new ReferencedEntityKey("b", ArgumentType.TS_ROLLING, null); + argumentB.setTimeWindow(TimeUnit.MINUTES.toMillis(10)); + argumentB.setLimit(1000); + argumentB.setRefEntityKey(refEntityKeyB); + config.setArguments(Map.of("a", argumentA, "b", argumentB)); + config.setExpression(""" + return { + "latestA": a, + "avgB": b.avg + }; + """); + + config.setOutput(new TimeSeriesOutput()); + + calculatedField.setConfiguration(config); + + doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(testDevice.getId(), "latestA", "avgB"); + assertThat(result).isNotNull(); + assertThat(result.get("latestA").get(0).get("value").asText()).isEqualTo("5"); + assertThat(result.get("avgB").get(0).get("value").asText()).isEqualTo("15.0"); + }); + + postTelemetry(testDevice.getId(), String.format(""" + [{ + "ts": %s, + "values": { + "a": 6, + "b": 100 + } + }, { + "ts": %s, + "values": { + "a": 7, + "b": 200 + } + }, { + "ts": %s, + "values": { + "a": 8, + "b": 300 + } + }]""", now - TimeUnit.MINUTES.toMillis(2), now, now - TimeUnit.MINUTES.toMillis(5))); + + await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(testDevice.getId(), "latestA", "avgB"); + assertThat(result).isNotNull(); + assertThat(result.get("latestA").get(0).get("value").asText()).isEqualTo("7"); + assertThat(result.get("avgB").get(0).get("value").asText()).isEqualTo("126.0"); + }); + } + + @Test + public void testSimpleCalculatedFieldWhenSkipRuleEngineOutputProcessing() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + + postTelemetry(testDevice.getId(), "{\"temperature\":24.5}"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("C to F"); + calculatedField.setDebugSettings(DebugSettings.all()); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + config.setArguments(Map.of("T", argument)); + config.setExpression("(T * 9/5) + 32"); + + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setName("fahrenheitTemp"); + output.setDecimalsByDefault(1); + output.setStrategy(new TimeSeriesImmediateOutputStrategy(1000L, true, true, true, true)); + + config.setOutput(output); + + config.setUseLatestTs(true); + + calculatedField.setConfiguration(config); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "fahrenheitTemp"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("76.1"); + }); + } + 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); } @@ -811,4 +1405,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/cf/EntityAggregationCalculatedFieldTest.java b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java new file mode 100644 index 0000000000..0872e21dae --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java @@ -0,0 +1,372 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cf; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.test.annotation.DirtiesContext; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.CustomInterval; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark; +import org.thingsboard.server.common.data.debug.DebugSettings; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.thingsboard.server.cf.CalculatedFieldIntegrationTest.POLL_INTERVAL; + +@DaoSqlTest +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest { + + private final String TZ = "Europe/Kyiv"; + + private Tenant savedTenant; + + @Before + public void beforeEach() throws Exception { + loginSysAdmin(); + + updateDefaultTenantProfileConfig(tenantProfileConfig -> { + tenantProfileConfig.setMinAllowedDeduplicationIntervalInSecForCF(1); + tenantProfileConfig.setMinAllowedAggregationIntervalInSecForCF(1); + tenantProfileConfig.setCfReevaluationCheckInterval(1); + }); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = saveTenant(tenant); + assertThat(savedTenant).isNotNull(); + + User tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant@thingsboard.org"); + tenantAdmin.setFirstName("John"); + tenantAdmin.setLastName("Doe"); + + createUserAndLogin(tenantAdmin, "testPassword"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + deleteTenant(savedTenant.getId()); + } + + @Test + public void testCreateCfAndNoTelemetryDuringInterval_checkAggregation() throws Exception { + Device device = createDevice("Device", "1234567890111"); + + CustomInterval customInterval = new CustomInterval(TZ, 0L, 5L); + createConsumptionCF(device.getId(), customInterval, null); + + long interval = customInterval.getCurrentIntervalDurationMillis(); + + await().alias("create CF and no telemetry during interval -> save metric with default value") + .atMost(2 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("9999"); + assertThat(result.get("avgConsumption").get(0).get("value").isNull()).isTrue(); + }); + } + + @Test + public void testCreateCfWithoutWatermark_checkAggregation() throws Exception { + Device device = createDevice("Device", "1234567890111"); + + CustomInterval customInterval = new CustomInterval(TZ, 0L, 5L); + createConsumptionCF(device.getId(), customInterval, null); + + long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); + + long tsBeforeInterval = currentIntervalStartTs - 1000; + long tsInInterval_1 = currentIntervalStartTs + 1000; + long tsInInterval_2 = currentIntervalStartTs + 500; + long tsInInterval_3 = currentIntervalStartTs + 200; + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsBeforeInterval)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3)); + + long interval = customInterval.getCurrentIntervalDurationMillis(); + + await().alias("create CF -> perform aggregation after interval end") + .atMost(2 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); + assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133"); + }); + + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":500}}", tsInInterval_1)); + + await().alias("update telemetry that belongs to previous interval -> no aggregation since watermark is not set ") + .atMost(2 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); + assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133"); + }); + } + + @Test + public void testCreateCfWithWatermark_checkAggregationDuringWatermark() throws Exception { + Device device = createDevice("Device", "1234567890111"); + + CustomInterval customInterval = new CustomInterval(TZ, 0L, 5L); + Watermark watermark = new Watermark(10); + createConsumptionCF(device.getId(), customInterval, watermark); + + long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); + + long tsBeforeInterval = currentIntervalStartTs - 1000L; + long tsInInterval_1 = currentIntervalStartTs + 1000L; + long tsInInterval_2 = currentIntervalStartTs + 500L; + long tsInInterval_3 = currentIntervalStartTs + 200L; + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsBeforeInterval)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3)); + + long interval = customInterval.getCurrentIntervalDurationMillis(); + + await().alias("create CF -> perform aggregation after interval end") + .atMost(2 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); + assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133"); + }); + + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":300}}", tsInInterval_1)); + + await().alias("update telemetry during watermark -> perform aggregation") + .atMost(2 * 10, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("600"); + assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("200"); + }); + } + + @Test + public void testSendFutureTelemetry_checkAggregation() throws Exception { + Device device = createDevice("Device", "1234567890111"); + + CustomInterval customInterval = new CustomInterval(TZ, 0L, 2L); + createConsumptionCF(device.getId(), customInterval, null); + + long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); + + long tsBeforeInterval = currentIntervalStartTs - 1000; + long tsInInterval_1 = currentIntervalStartTs + 1000; + long tsInInterval_2 = currentIntervalStartTs + 500; + long tsInInterval_3 = currentIntervalStartTs + 200; + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsBeforeInterval)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3)); + + long interval = customInterval.getCurrentIntervalDurationMillis(); + + await().alias("create CF -> perform aggregation after interval end") + .atMost(2 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); + assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("133"); + }); + + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":500}}", currentIntervalStartTs + 4500L)); + + await().alias("update telemetry that belongs to future interval -> check aggregation ") + .atMost(3 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgConsumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("500"); + assertThat(result.get("consumption").get(0).get("ts").asLong()).isEqualTo(currentIntervalStartTs + 4000L); + assertThat(result.get("avgConsumption").get(0).get("value").asText()).isEqualTo("500"); + assertThat(result.get("avgConsumption").get(0).get("ts").asLong()).isEqualTo(currentIntervalStartTs + 4000L); + }); + } + + private CalculatedField createConsumptionCF(EntityId entityId, AggInterval aggInterval, Watermark watermark) { + Map arguments = new HashMap<>(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null)); + arguments.put("en", argument); + + Map aggMetrics = new HashMap<>(); + + AggMetric consumption = new AggMetric(); + consumption.setFunction(AggFunction.SUM); + consumption.setInput(new AggKeyInput("en")); + consumption.setDefaultValue(9999.0); + aggMetrics.put("consumption", consumption); + + AggMetric avgEnergyConsumption = new AggMetric(); + avgEnergyConsumption.setFunction(AggFunction.AVG); + avgEnergyConsumption.setInput(new AggKeyInput("en")); + aggMetrics.put("avgConsumption", avgEnergyConsumption); + + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setDecimalsByDefault(0); + + return createAggCf("Consumption", entityId, + aggInterval, + watermark, + arguments, + aggMetrics, + output); + } + + @Test + public void testCreateCfWith2Arguments_checkAggregation() throws Exception { + Device device = createDevice("Device", "1234567890111"); + + CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L); + createCFWith2Args(device.getId(), customInterval, null); + + long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); + + long tsBeforeInterval = currentIntervalStartTs - 1000; + long tsInInterval_1 = currentIntervalStartTs + 1000; + long tsInInterval_2 = currentIntervalStartTs + 500; + long tsInInterval_3 = currentIntervalStartTs + 200; + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120, \"temperature\":43}}", tsBeforeInterval)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100, \"temperature\":39}}", tsInInterval_1)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180, \"temperature\":27}}", tsInInterval_2)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120, \"temperature\":50}}", tsInInterval_3)); + + long interval = customInterval.getCurrentIntervalDurationMillis(); + + await().alias("create CF -> perform aggregation after interval end") + .atMost(2 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption", "avgTemperature"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); + assertThat(result.get("avgTemperature").get(0).get("value").asText()).isEqualTo("39"); + }); + } + + private CalculatedField createCFWith2Args(EntityId entityId, AggInterval aggInterval, Watermark watermark) { + Map arguments = new HashMap<>(); + Argument energy = new Argument(); + energy.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null)); + arguments.put("en", energy); + + Argument temperature = new Argument(); + temperature.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + arguments.put("temp", temperature); + + Map aggMetrics = new HashMap<>(); + + AggMetric consumption = new AggMetric(); + consumption.setFunction(AggFunction.SUM); + consumption.setInput(new AggKeyInput("en")); + consumption.setDefaultValue(9999.0); + aggMetrics.put("consumption", consumption); + + AggMetric avgTemperature = new AggMetric(); + avgTemperature.setFunction(AggFunction.AVG); + avgTemperature.setInput(new AggKeyInput("temp")); + aggMetrics.put("avgTemperature", avgTemperature); + + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setDecimalsByDefault(0); + + return createAggCf("CF with 2 args", entityId, + aggInterval, + watermark, + arguments, + aggMetrics, + output); + } + + private CalculatedField createAggCf(String name, + EntityId entityId, + AggInterval aggInterval, + Watermark watermark, + Map inputs, + Map metrics, + Output output) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setName(name); + calculatedField.setEntityId(entityId); + calculatedField.setType(CalculatedFieldType.ENTITY_AGGREGATION); + + EntityAggregationCalculatedFieldConfiguration configuration = new EntityAggregationCalculatedFieldConfiguration(); + + configuration.setArguments(inputs); + configuration.setMetrics(metrics); + configuration.setInterval(aggInterval); + if (watermark != null) { + configuration.setWatermark(watermark); + } + configuration.setOutput(output); + + calculatedField.setConfiguration(configuration); + calculatedField.setDebugSettings(DebugSettings.all()); + return saveCalculatedField(calculatedField); + } + + private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { + return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java b/application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java new file mode 100644 index 0000000000..4c708bce56 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java @@ -0,0 +1,978 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cf; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.test.annotation.DirtiesContext; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.AttributesOutput; +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.TimeSeriesOutput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.debug.DebugSettings; +import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration; +import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; +import org.thingsboard.server.common.data.device.data.DeviceData; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.cf.CalculatedFieldIntegrationTest.POLL_INTERVAL; + +@DaoSqlTest +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +public class RelatedEntitiesAggregationCalculatedFieldTest extends AbstractControllerTest { + + private Tenant savedTenant; + + private DeviceProfile deviceProfile; + private Device device1; + private String accessToken1 = "1234567890111"; + private Device device2; + private String accessToken2 = "1234567890222"; + + private AssetProfile assetProfile; + private Asset asset; + + private final long deduplicationInterval = 5; + + @Before + public void beforeEach() throws Exception { + loginSysAdmin(); + + updateDefaultTenantProfileConfig(tenantProfileConfig -> { + tenantProfileConfig.setMinAllowedDeduplicationIntervalInSecForCF(1); + tenantProfileConfig.setMinAllowedScheduledUpdateIntervalInSecForCF(1); + }); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = saveTenant(tenant); + assertThat(savedTenant).isNotNull(); + + User tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant@thingsboard.org"); + tenantAdmin.setFirstName("John"); + tenantAdmin.setLastName("Doe"); + + createUserAndLogin(tenantAdmin, "testPassword"); + + deviceProfile = doPost("/api/deviceProfile", createDeviceProfile("Device Profile"), DeviceProfile.class); + device1 = createDevice("Device 1", deviceProfile.getId(), accessToken1); + device2 = createDevice("Device 2", deviceProfile.getId(), accessToken2); + + postTelemetry(device1.getId(), "{\"occupied\":true}"); + postTelemetry(device2.getId(), "{\"occupied\":false}"); + + assetProfile = doPost("/api/assetProfile", createAssetProfile("Asset Profile"), AssetProfile.class); + asset = createAsset("Asset", assetProfile.getId()); + + createEntityRelation(asset.getId(), device1.getId(), "Contains"); + createEntityRelation(asset.getId(), device2.getId(), "Contains"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + deleteTenant(savedTenant.getId()); + } + + @Test + public void testCreateCfOnProfile_checkInitialAggregation() throws Exception { + Asset asset2 = createAsset("Asset 2", assetProfile.getId()); + Device device3 = createDevice("Device 3", "1234567890333"); + Device device4 = createDevice("Device 4", "1234567890444"); + + createEntityRelation(asset2.getId(), device3.getId(), "Contains"); + createEntityRelation(asset2.getId(), device4.getId(), "Contains"); + + createOccupancyCF(assetProfile.getId()); + + await().alias("create CF and perform initial aggregation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "1", + "totalSpaces", "2" + )); + + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + + postTelemetry(device3.getId(), "{\"occupied\":true}"); + + await().alias("update telemetry and perform aggregation") + .atLeast(deduplicationInterval / 2, TimeUnit.SECONDS) + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "1", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testAddEntityToProfile_checkAggregation() throws Exception { + createOccupancyCF(assetProfile.getId()); + + Device device3 = createDevice("Device 3", "1234567890333"); + Device device4 = createDevice("Device 4", "1234567890444"); + postTelemetry(device3.getId(), "{\"occupied\":true}"); + postTelemetry(device4.getId(), "{\"occupied\":true}"); + + Asset asset2 = createAsset("Asset 2", assetProfile.getId()); + + await().alias("add entity to profile with no related entities and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode occupancy = getLatestTelemetry(asset2.getId(), "freeSpaces", "occupiedSpaces", "totalSpaces"); + assertThat(occupancy).isNotNull(); + assertThat(occupancy.get("freeSpaces").get(0).get("value").isNull()).isTrue(); + assertThat(occupancy.get("occupiedSpaces").get(0).get("value").isNull()).isTrue(); + assertThat(occupancy.get("totalSpaces").get(0).get("value").isNull()).isTrue(); + }); + + createEntityRelation(asset2.getId(), device3.getId(), "Contains"); + createEntityRelation(asset2.getId(), device4.getId(), "Contains"); + + await().alias("create relations and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "0", + "occupiedSpaces", "2", + "totalSpaces", "2" + )); + }); + + postTelemetry(device3.getId(), "{\"occupied\":false}"); + + await().alias("update telemetry and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "1", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testChangeEntityProfile_checkAggregation() throws Exception { + Asset asset2 = createAsset("Asset 2", assetProfile.getId()); + Device device3 = createDevice("Device 3", "1234567890333"); + Device device4 = createDevice("Device 4", "1234567890444"); + + createEntityRelation(asset2.getId(), device3.getId(), "Contains"); + createEntityRelation(asset2.getId(), device4.getId(), "Contains"); + + createOccupancyCF(assetProfile.getId()); + + await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "1", + "totalSpaces", "2" + )); + + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + + AssetProfile newAssetProfile = createAssetProfile("New Asset Profile"); + asset2.setAssetProfileId(newAssetProfile.getId()); + doPost("/api/asset", asset2, Asset.class); + + postTelemetry(device3.getId(), "{\"occupied\":true}"); + + await().alias("change profile and no aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testCreateCfOnAssetAndNoTelemetryOnDevices_checkDefaultValueUsed() throws Exception { + Asset asset2 = createAsset("Asset 2", assetProfile.getId()); + Device device3 = createDevice("Device 3", "1234567890333"); + Device device4 = createDevice("Device 4", "1234567890444"); + + createEntityRelation(asset2.getId(), device3.getId(), "Contains"); + createEntityRelation(asset2.getId(), device4.getId(), "Contains"); + + createOccupancyCF(asset2.getId()); + + await().alias("create CF and perform aggregation with default values").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testCreateCfAndUpdateTelemetry_checkAggregation() throws Exception { + createOccupancyCF(asset.getId()); + checkInitialCalculation(); + + postTelemetry(device1.getId(), "{\"occupied\":false}"); + + await().alias("update telemetry and perform aggregation") + .atLeast(deduplicationInterval / 2, TimeUnit.SECONDS) + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testCreateCfAndRelationToRuleChain_checkAggregation() throws Exception { + Asset asset2 = createAsset("Asset 2", assetProfile.getId()); + Device device3 = createDevice("Device 3", "1234567890333"); + postTelemetry(device3.getId(), "{\"occupied\":true}"); + + RuleChain ruleChain = new RuleChain(); + ruleChain.setName("RuleChain"); + ruleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); + postTelemetry(ruleChain.getId(), "{\"occupied\":true}"); + + createEntityRelation(asset2.getId(), device3.getId(), "Contains"); + createEntityRelation(asset2.getId(), ruleChain.getId(), "Contains"); + + createOccupancyCF(asset2.getId()); + + await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "0", + "occupiedSpaces", "1", + "totalSpaces", "1" + )); + }); + + postTelemetry(ruleChain.getId(), "{\"occupied\":true}"); + + await().alias("update telemetry on rule chain and no aggregation performed").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "0", + "occupiedSpaces", "1", + "totalSpaces", "1" + )); + }); + } + + @Test + public void testDeleteCf_checkNoAggregation() throws Exception { + CalculatedField cf = createOccupancyCF(asset.getId()); + checkInitialCalculation(); + + doDelete("/api/calculatedField/" + cf.getId().getId().toString()) + .andExpect(status().isOk()); + + postTelemetry(device1.getId(), "{\"occupied\":false}"); + + await().alias("delete cf and update telemetry and no aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "1", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testUpdateTelemetry_checkAggregationNotExecutedUntilDeduplicationInterval() throws Exception { + createOccupancyCF(asset.getId()); + checkInitialCalculation(); + + postTelemetry(device1.getId(), "{\"occupied\":false}"); + + await().alias("update telemetry -> no changes").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(this::checkInitialCalculationValues); + + postTelemetry(device2.getId(), "{\"occupied\":false}"); + + await().alias("create CF and perform initial calculation") + .atLeast(deduplicationInterval / 2, TimeUnit.SECONDS) + .atMost(TIMEOUT, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testDeleteTelemetry_checkAggregationWithPreviousValuesOrDefault() throws Exception { + Asset asset2 = createAsset("Asset 2", assetProfile.getId()); + Device device3 = createDevice("Device 3", "1234567890333"); + Device device4 = createDevice("Device 4", "1234567890444"); + + createEntityRelation(asset2.getId(), device3.getId(), "Contains"); + createEntityRelation(asset2.getId(), device4.getId(), "Contains"); + + long currentTime = System.currentTimeMillis(); + long firstTs = currentTime - 10; + long secondTs = currentTime - 10; + long thirdTs = currentTime - 5; + postTelemetry(device3.getId(), "{\"ts\": " + firstTs + ", \"values\": {\"occupied\":true}}"); + postTelemetry(device4.getId(), "{\"ts\": " + secondTs + ", \"values\": {\"occupied\":true}}"); + postTelemetry(device3.getId(), "{\"ts\": " + thirdTs + ", \"values\": {\"occupied\":true}}"); + + createOccupancyCF(asset2.getId()); + + await().alias("create CF and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "0", + "occupiedSpaces", "2", + "totalSpaces", "2" + )); + }); + + doDelete("/api/plugins/telemetry/DEVICE/" + device3.getId() + "/timeseries/delete?keys=occupied&deleteAllDataForKeys=false&rewriteLatestIfDeleted=true&deleteLatest=true&startTs=" + thirdTs + "&endTs=" + thirdTs + 1, String.class); + doDelete("/api/plugins/telemetry/DEVICE/" + device4.getId() + "/timeseries/delete?keys=occupied&deleteAllDataForKeys=false&rewriteLatestIfDeleted=true&deleteLatest=true&startTs=" + secondTs + "&endTs=" + secondTs + 1, String.class); + + await().alias("delete latest telemetry and perform aggregation with previous or default values").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "1", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testDeleteAttr_checkAggregationWithDefault() throws Exception { + Asset asset2 = createAsset("Asset 2", assetProfile.getId()); + Device device3 = createDevice("Device 3", "1234567890333"); + Device device4 = createDevice("Device 4", "1234567890444"); + + createEntityRelation(asset2.getId(), device3.getId(), "Contains"); + createEntityRelation(asset2.getId(), device4.getId(), "Contains"); + + postAttributes(device3.getId(), AttributeScope.SERVER_SCOPE, "{\"occupied\":true}"); + postAttributes(device4.getId(), AttributeScope.SERVER_SCOPE, "{\"occupied\":true}"); + + createOccupancyCFWithAttr(asset2.getId()); + + await().alias("create CF and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "0", + "occupiedSpaces", "2", + "totalSpaces", "2" + )); + }); + + doDelete("/api/plugins/telemetry/DEVICE/" + device3.getUuidId() + "/SERVER_SCOPE?keys=occupied", String.class); + doDelete("/api/plugins/telemetry/DEVICE/" + device4.getUuidId() + "/SERVER_SCOPE?keys=occupied", String.class); + + await().alias("delete attribute and perform aggregation with default values").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testCreateRelation_checkAggregation() throws Exception { + Asset asset2 = createAsset("Asset 2", assetProfile.getId()); + Device device3 = createDevice("Device 3", "1234567890333"); + Device device4 = createDevice("Device 4", "1234567890444"); + + createEntityRelation(asset2.getId(), device3.getId(), "Contains"); + createEntityRelation(asset2.getId(), device4.getId(), "Contains"); + + createOccupancyCF(assetProfile.getId()); + + await().alias("create CF and perform initial aggregation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "1", + "totalSpaces", "2" + )); + + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + + Device device5 = createDevice("Device 5", "1234567890555"); + createEntityRelation(asset2.getId(), device5.getId(), "Contains"); + + await().alias("create relation and perform aggregation on asset 2") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "1", + "totalSpaces", "2" + )); + + verifyTelemetry(asset2.getId(), Map.of( + "freeSpaces", "3", + "occupiedSpaces", "0", + "totalSpaces", "3" + )); + }); + } + + @Test + public void testDeleteRelation_checkAggregation() throws Exception { + createOccupancyCF(asset.getId()); + checkInitialCalculation(); + + deleteEntityRelation(new EntityRelation(asset.getId(), device1.getId(), "Contains", RelationTypeGroup.COMMON)); + + await().alias("create relation and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "0", + "totalSpaces", "1" + )); + }); + } + + @Test + public void testDeleteEntityByRelation_checkAggregation() throws Exception { + createOccupancyCF(asset.getId()); + checkInitialCalculation(); + + doDelete("/api/device/" + device1.getId()).andExpect(status().isOk()); + + await().alias("create relation and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "1", + "occupiedSpaces", "0", + "totalSpaces", "1" + )); + }); + } + + @Test + public void testUpdateRelationPath_checkAggregation() throws Exception { + CalculatedField cf = createOccupancyCF(asset.getId()); + checkInitialCalculation(); + + Device device3 = createDevice("Device 3", "1234567890333"); + createEntityRelation(asset.getId(), device3.getId(), "Has"); + postTelemetry(device3.getId(), "{\"occupied\":true}"); + + var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); + configuration.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, "Has")); + saveCalculatedField(cf); + + await().alias("update relation path and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "0", + "occupiedSpaces", "1", + "totalSpaces", "1" + )); + }); + } + + @Test + public void testUpdateArguments_checkAggregation() throws Exception { + CalculatedField cf = createOccupancyCF(asset.getId()); + checkInitialCalculation(); + + postTelemetry(device1.getId(), "{\"occupiedStatus\":false}"); + postTelemetry(device2.getId(), "{\"occupiedStatus\":false}"); + + var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("oc", ArgumentType.TS_LATEST, null)); + argument.setDefaultValue("false"); + configuration.setArguments(Map.of("oc", argument)); + saveCalculatedField(cf); + + await().alias("update arguments and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of( + "freeSpaces", "2", + "occupiedSpaces", "0", + "totalSpaces", "2" + )); + }); + } + + @Test + public void testUpdateMetrics_checkAggregation() throws Exception { + postTelemetry(device1.getId(), "{\"temperature\":24.2}"); + postTelemetry(device2.getId(), "{\"temperature\":19.6}"); + CalculatedField cf = createAvgTemperatureCF(asset.getId()); + + await().alias("create avg temp cf and perform initial aggregation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24")); + }); + + var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); + AggMetric aggMetric = new AggMetric(); + aggMetric.setInput(new AggKeyInput("temp")); + aggMetric.setFilter("return temp < 100;"); + aggMetric.setFunction(AggFunction.MAX); + configuration.setMetrics(Map.of("maxTemperature", aggMetric)); + saveCalculatedField(cf); + + await().alias("update metrics and perform aggregation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of("maxTemperature", "24")); + }); + + postTelemetry(device1.getId(), "{\"temperature\":101.3}"); + postTelemetry(device2.getId(), "{\"temperature\":25.8}"); + + await().alias("update telemetry and perform aggregation") + .atLeast(deduplicationInterval / 2, TimeUnit.SECONDS) + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of("maxTemperature", "26")); + }); + } + + @Test + public void testUpdateOutput_checkAggregation() throws Exception { + postTelemetry(device1.getId(), "{\"temperature\":24.2}"); + postTelemetry(device2.getId(), "{\"temperature\":19.6}"); + CalculatedField cf = createAvgTemperatureCF(asset.getId()); + + await().alias("create avg temp cf and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24")); + }); + + var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); + AttributesOutput output = new AttributesOutput(); + output.setScope(AttributeScope.SERVER_SCOPE); + configuration.setOutput(output); + saveCalculatedField(cf); + + await().alias("update output and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode avgTemperature = getServerAttributes(asset.getId(), "avgTemperature"); + assertThat(avgTemperature).isNotNull(); + assertThat(avgTemperature.get(0)).isNotNull(); + assertThat(avgTemperature.get(0).get("value").asText()).isEqualTo("24.2"); + }); + } + + @Test + public void testUpdateDeduplicationInterval_checkAggregationNotExecutedUntilDeduplicationInterval() throws Exception { + postTelemetry(device1.getId(), "{\"temperature\":24.2}"); + postTelemetry(device2.getId(), "{\"temperature\":19.6}"); + CalculatedField cf = createAvgTemperatureCF(asset.getId()); + + await().alias("create avg temp cf and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24")); + }); + + var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); + configuration.setDeduplicationIntervalInSec(2 * deduplicationInterval); + saveCalculatedField(cf); + + await().alias("update deduplication interval and perform aggregation").atMost(deduplicationInterval / 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24")); + }); + + postTelemetry(device2.getId(), "{\"temperature\":32.1}"); + + await().alias("update telemetry and perform aggregation").atMost(2 * deduplicationInterval + 10, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verifyTelemetry(asset.getId(), Map.of("avgTemperature", "28")); + }); + } + + + @Test + public void testTheSameRelationTypeAndKeyButDifferentCfs_checkAggregation() throws Exception { + postTelemetry(device1.getId(), "{\"temperature\":24.2}"); + postTelemetry(device2.getId(), "{\"temperature\":19.6}"); + CalculatedField cf = createAvgTemperatureCF(asset.getId()); + + Asset asset2 = createAsset("Asset 2", assetProfile.getId()); + Device device3 = createDevice("Device 3", "1234567890333"); + createEntityRelation(asset2.getId(), device3.getId(), "Contains"); + postTelemetry(device3.getId(), "{\"temperature\":10.2}"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setName("Average temperature"); + calculatedField.setEntityId(asset2.getId()); + calculatedField.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + + var config = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); + AggMetric avgMetric = new AggMetric(); + avgMetric.setFunction(AggFunction.AVG); + avgMetric.setInput(new AggKeyInput("temp")); + config.setMetrics(Map.of("avgTemperature_2", avgMetric)); + + calculatedField.setConfiguration(config); + calculatedField.setDebugSettings(DebugSettings.all()); + saveCalculatedField(calculatedField); + + await().alias("create avg temp cf and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode avgTemperature = getLatestTelemetry(asset.getId(), "avgTemperature", "avgTemperature_2"); + assertThat(avgTemperature).isNotNull(); + assertThat(avgTemperature.get("avgTemperature").get(0).get("value").asText()).isEqualTo("24"); + assertThat(avgTemperature.get("avgTemperature_2").get(0).get("value").isNull()).isTrue(); + }); + + postTelemetry(device1.getId(), "{\"temperature\":26.2}"); + + await().alias("create avg temp cf and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode avgTemperature = getLatestTelemetry(asset.getId(), "avgTemperature", "avgTemperature_2"); + assertThat(avgTemperature).isNotNull(); + assertThat(avgTemperature.get("avgTemperature").get(0).get("value").asText()).isEqualTo("26"); + assertThat(avgTemperature.get("avgTemperature_2").get(0).get("value").isNull()).isTrue(); + }); + } + + @Test + public void testUpdateMaxRelatedEntitiesPerArgument_checkAggregation() throws Exception { + loginSysAdmin(); + + updateDefaultTenantProfileConfig(tenantProfileConfig -> { + tenantProfileConfig.setMaxRelatedEntitiesToReturnPerCfArgument(1); + }); + + login("tenant@thingsboard.org", "testPassword"); + + createCountCF(asset.getId()); + + await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode numberOfDevices = getLatestTelemetry(asset.getId(), "numberOfDevices"); + assertThat(numberOfDevices).isNotNull(); + assertThat(numberOfDevices.get("numberOfDevices").get(0).get("value").asText()).isEqualTo("1"); + }); + + loginSysAdmin(); + + updateDefaultTenantProfileConfig(tenantProfileConfig -> { + tenantProfileConfig.setMaxRelatedEntitiesToReturnPerCfArgument(10); + }); + + login("tenant@thingsboard.org", "testPassword"); + + await().alias("update max related entities per argument and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode numberOfDevices = getLatestTelemetry(asset.getId(), "numberOfDevices"); + assertThat(numberOfDevices).isNotNull(); + assertThat(numberOfDevices.get("numberOfDevices").get(0).get("value").asText()).isEqualTo("2"); + }); + } + + private void checkInitialCalculation() { + await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(this::checkInitialCalculationValues); + } + + private void checkInitialCalculationValues() throws Exception { + ObjectNode occupancy = getLatestTelemetry(asset.getId(), "freeSpaces", "occupiedSpaces", "totalSpaces"); + assertThat(occupancy).isNotNull(); + assertThat(occupancy.get("freeSpaces").get(0).get("value").asText()).isEqualTo("1"); + assertThat(occupancy.get("occupiedSpaces").get(0).get("value").asText()).isEqualTo("1"); + assertThat(occupancy.get("totalSpaces").get(0).get("value").asText()).isEqualTo("2"); + } + + private CalculatedField createAvgTemperatureCF(EntityId entityId) { + Map arguments = new HashMap<>(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + argument.setDefaultValue("20"); + arguments.put("temp", argument); + + Map aggMetrics = new HashMap<>(); + + AggMetric avgMetric = new AggMetric(); + avgMetric.setFunction(AggFunction.AVG); + avgMetric.setFilter("return temp >= 20;"); + avgMetric.setInput(new AggKeyInput("temp")); + aggMetrics.put("avgTemperature", avgMetric); + + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setDecimalsByDefault(0); + + return createAggCf("Average temperature", entityId, + new RelationPathLevel(EntitySearchDirection.FROM, "Contains"), + arguments, + aggMetrics, + output); + } + + private CalculatedField createOccupancyCF(EntityId entityId) { + Map arguments = new HashMap<>(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("occupied", ArgumentType.TS_LATEST, null)); + argument.setDefaultValue("false"); + arguments.put("oc", argument); + + Map aggMetrics = new HashMap<>(); + + AggMetric freeSpaces = new AggMetric(); + freeSpaces.setFunction(AggFunction.COUNT); + freeSpaces.setFilter("return oc == false;"); + freeSpaces.setInput(new AggKeyInput("oc")); + aggMetrics.put("freeSpaces", freeSpaces); + + AggMetric occupiedSpaces = new AggMetric(); + occupiedSpaces.setFunction(AggFunction.COUNT); + occupiedSpaces.setFilter("return oc == true;"); + occupiedSpaces.setInput(new AggKeyInput("oc")); + aggMetrics.put("occupiedSpaces", occupiedSpaces); + + AggMetric totalSpaces = new AggMetric(); + totalSpaces.setFunction(AggFunction.COUNT); + totalSpaces.setInput(new AggFunctionInput("return 1;")); + aggMetrics.put("totalSpaces", totalSpaces); + + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setDecimalsByDefault(0); + + return createAggCf("Occupied spaces", entityId, + new RelationPathLevel(EntitySearchDirection.FROM, "Contains"), + arguments, + aggMetrics, + output); + } + + private CalculatedField createOccupancyCFWithAttr(EntityId entityId) { + Map arguments = new HashMap<>(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("occupied", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + argument.setDefaultValue("false"); + arguments.put("oc", argument); + + Map aggMetrics = new HashMap<>(); + + AggMetric freeSpaces = new AggMetric(); + freeSpaces.setFunction(AggFunction.COUNT); + freeSpaces.setFilter("return oc == false;"); + freeSpaces.setInput(new AggKeyInput("oc")); + aggMetrics.put("freeSpaces", freeSpaces); + + AggMetric occupiedSpaces = new AggMetric(); + occupiedSpaces.setFunction(AggFunction.COUNT); + occupiedSpaces.setFilter("return oc == true;"); + occupiedSpaces.setInput(new AggKeyInput("oc")); + aggMetrics.put("occupiedSpaces", occupiedSpaces); + + AggMetric totalSpaces = new AggMetric(); + totalSpaces.setFunction(AggFunction.COUNT); + totalSpaces.setInput(new AggFunctionInput("return 1;")); + aggMetrics.put("totalSpaces", totalSpaces); + + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setDecimalsByDefault(0); + + return createAggCf("Occupied spaces", entityId, + new RelationPathLevel(EntitySearchDirection.FROM, "Contains"), + arguments, + aggMetrics, + output); + } + + private CalculatedField createCountCF(EntityId entityId) { + Map arguments = new HashMap<>(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("active", ArgumentType.TS_LATEST, null)); + argument.setDefaultValue("true"); + arguments.put("active", argument); + + Map aggMetrics = new HashMap<>(); + + AggMetric avgMetric = new AggMetric(); + avgMetric.setFunction(AggFunction.COUNT); + avgMetric.setInput(new AggKeyInput("active")); + aggMetrics.put("numberOfDevices", avgMetric); + + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setDecimalsByDefault(0); + + return createAggCf("Number of devices", entityId, + new RelationPathLevel(EntitySearchDirection.FROM, "Contains"), + arguments, + aggMetrics, + output); + } + + private CalculatedField createAggCf(String name, + EntityId entityId, + RelationPathLevel relation, + Map inputs, + Map metrics, + Output output) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setName(name); + calculatedField.setEntityId(entityId); + calculatedField.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + + RelatedEntitiesAggregationCalculatedFieldConfiguration configuration = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); + configuration.setRelation(relation); + configuration.setArguments(inputs); + configuration.setDeduplicationIntervalInSec(deduplicationInterval); + configuration.setScheduledUpdateInterval(10); + configuration.setMetrics(metrics); + configuration.setOutput(output); + + calculatedField.setConfiguration(configuration); + calculatedField.setDebugSettings(DebugSettings.all()); + return saveCalculatedField(calculatedField); + } + + private Device createDevice(String name, DeviceProfileId deviceProfileId, String accessToken) { + Device device = new Device(); + device.setName(name); + device.setDeviceProfileId(deviceProfileId); + DeviceData deviceData = new DeviceData(); + deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); + deviceData.setConfiguration(new DefaultDeviceConfiguration()); + device.setDeviceData(deviceData); + return doPost("/api/device?accessToken=" + accessToken, device, Device.class); + } + + private Asset createAsset(String name, AssetProfileId assetProfileId) { + Asset asset = new Asset(); + asset.setName(name); + asset.setAssetProfileId(assetProfileId); + return doPost("/api/asset", asset, Asset.class); + } + + private void verifyTelemetry(EntityId entityId, Map expectedResults) throws Exception { + ObjectNode result = getLatestTelemetry(entityId, expectedResults.keySet().toArray(new String[0])); + assertThat(result).isNotNull(); + expectedResults.forEach((key, value) -> assertThat(result.get(key).get(0).get("value").asText()).isEqualTo(value)); + } + + private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { + return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); + } + + private ArrayNode getServerAttributes(EntityId entityId, String... keys) throws Exception { + return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/attributes/SERVER_SCOPE?keys=" + String.join(",", keys), ArrayNode.class); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java index 32d7ba56ac..c25dafa5e6 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractInMemoryStorageTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractInMemoryStorageTest.java index ea5fd0291b..9642884ea2 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractInMemoryStorageTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractInMemoryStorageTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java index d4eec0d648..fcbd60eb4d 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -212,8 +212,7 @@ public abstract class AbstractNotifyEntityTest extends AbstractWebTest { testNotificationMsgToEdgeServiceNeverWithActionType(entityId, actionType); ArgumentMatcher matcherEntityClassEquals = argument -> argument.getClass().equals(entity.getClass()); ArgumentMatcher matcherOriginatorId = argument -> argument.getClass().equals(originatorId.getClass()); - ArgumentMatcher matcherCustomerId = customerId == null ? - argument -> argument.getClass().equals(CustomerId.class) : argument -> argument.equals(customerId); + ArgumentMatcher matcherCustomerId = customerId == null ? argument -> true : actualCustomerId -> actualCustomerId.equals(customerId); ArgumentMatcher matcherUserId = userId == null ? argument -> argument.getClass().equals(UserId.class) : argument -> argument.equals(userId); testLogEntityActionAdditionalInfoAny(matcherEntityClassEquals, matcherOriginatorId, tenantId, matcherCustomerId, matcherUserId, userName, actionType, cntTime, @@ -638,7 +637,7 @@ public abstract class AbstractNotifyEntityTest extends AbstractWebTest { return fieldName + " length must be equal or less than 255"; } - protected String msgErrorNoFound(String entityClassName, String entityIdStr) { + protected static String msgErrorNoFound(String entityClassName, String entityIdStr) { return entityClassName + " with id [" + entityIdStr + "] is not found"; } diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java index cf9d2feb23..0cf114fa35 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,19 +15,13 @@ */ package org.thingsboard.server.controller; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.TestPropertySource; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.EventInfo; -import org.thingsboard.server.common.data.event.EventType; -import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.RuleChainId; -import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.msg.TbMsgType; -import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.dao.rule.RuleChainService; @@ -61,18 +55,6 @@ public abstract class AbstractRuleEngineControllerTest extends AbstractControlle return doGet("/api/ruleChain/metadata/" + ruleChainId.getId().toString(), RuleChainMetaData.class); } - protected PageData getDebugEvents(TenantId tenantId, EntityId entityId, int limit) throws Exception { - return getEvents(tenantId, entityId, EventType.DEBUG_RULE_NODE.getOldName(), limit); - } - - protected PageData getEvents(TenantId tenantId, EntityId entityId, String eventType, int limit) throws Exception { - TimePageLink pageLink = new TimePageLink(limit); - return doGetTypedWithTimePageLink("/api/events/{entityType}/{entityId}/{eventType}?tenantId={tenantId}&", - new TypeReference>() { - }, pageLink, entityId.getEntityType(), entityId.getId(), eventType, tenantId.getId()); - } - - protected JsonNode getMetadata(EventInfo outEvent) { String metaDataStr = outEvent.getBody().get("metadata").asText(); try { diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index 54b2bbf6cf..457f723644 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -68,17 +68,24 @@ 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; import org.thingsboard.server.actors.device.ToDeviceRpcRequestMetadata; import org.thingsboard.server.actors.service.DefaultActorService; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EventInfo; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TbResourceInfo; @@ -86,6 +93,9 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldInfo; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration; import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; import org.thingsboard.server.common.data.device.data.DeviceData; @@ -98,7 +108,9 @@ import org.thingsboard.server.common.data.device.profile.MqttTopics; import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadConfiguration; import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration; import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.event.EventType; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; @@ -138,6 +150,7 @@ import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.common.data.security.model.JwtPair; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.common.msg.session.FeatureType; @@ -150,6 +163,8 @@ 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.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileService; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRequest; import org.thingsboard.server.service.security.auth.rest.LoginRequest; @@ -188,6 +203,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; import static org.thingsboard.server.common.data.CacheConstants.CLAIM_DEVICES_CACHE; +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.API_KEY_HEADER_PREFIX; +import static org.thingsboard.server.config.ThingsboardSecurityConfiguration.BEARER_HEADER_PREFIX; @Slf4j public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { @@ -198,13 +215,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected static final String TEST_DIFFERENT_TENANT_NAME = "TEST DIFFERENT TENANT"; protected static final String SYS_ADMIN_EMAIL = "sysadmin@thingsboard.org"; - private static final String SYS_ADMIN_PASSWORD = "sysadmin"; + protected static final String SYS_ADMIN_PASSWORD = "sysadmin"; protected static final String TENANT_ADMIN_EMAIL = "testtenant@thingsboard.org"; protected static final String TENANT_ADMIN_PASSWORD = "tenant"; protected static final String DIFFERENT_TENANT_ADMIN_EMAIL = "testdifftenant@thingsboard.org"; - private static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant"; + protected static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant"; protected static final String CUSTOMER_USER_EMAIL = "testcustomer@thingsboard.org"; private static final String CUSTOMER_USER_PASSWORD = "customer"; @@ -233,6 +250,8 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected String mobileToken; protected String username; + protected String apiKey; + protected TenantId tenantId; protected TenantProfileId tenantProfileId; protected UserId tenantAdminUserId; @@ -600,8 +619,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { Assert.assertNotNull(tokenInfo); Assert.assertTrue(tokenInfo.has("token")); Assert.assertTrue(tokenInfo.has("refreshToken")); - String token = tokenInfo.get("token").asText(); - String refreshToken = tokenInfo.get("refreshToken").asText(); + validateAndSetJwtToken(JacksonUtil.treeToValue(tokenInfo, JwtPair.class), username); + } + + protected void validateAndSetJwtToken(JwtPair jwtPair, String username) { + Assert.assertNotNull(jwtPair); + String token = jwtPair.getToken(); + String refreshToken = jwtPair.getRefreshToken(); validateJwtToken(token, username); validateJwtToken(refreshToken, username); this.token = token; @@ -630,13 +654,27 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected void setJwtToken(MockHttpServletRequestBuilder request) { if (this.token != null) { - request.header(ThingsboardSecurityConfiguration.JWT_TOKEN_HEADER_PARAM, "Bearer " + this.token); + request.header(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER, BEARER_HEADER_PREFIX + this.token); } if (this.mobileToken != null) { request.header(UserController.MOBILE_TOKEN_HEADER, this.mobileToken); } } + protected void resetApiKey() { + this.apiKey = null; + } + + protected void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + protected void setApiKey(MockHttpServletRequestBuilder request) { + if (this.apiKey != null) { + request.header(ThingsboardSecurityConfiguration.AUTHORIZATION_HEADER, API_KEY_HEADER_PREFIX + this.apiKey); + } + } + protected DeviceProfile createDeviceProfile(String name) { return createDeviceProfile(name, null); } @@ -672,9 +710,14 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { } protected Device createDevice(String name, String accessToken) throws Exception { + return createDevice(name, "default", null, accessToken); + } + + protected Device createDevice(String name, String type, String label, String accessToken) throws Exception { Device device = new Device(); device.setName(name); - device.setType("default"); + device.setType(type); + device.setLabel(label); DeviceData deviceData = new DeviceData(); deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); deviceData.setConfiguration(new DefaultDeviceConfiguration()); @@ -730,6 +773,12 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return doPost("/api/device-with-credentials", request, Device.class); } + protected ResultActions doGetAsync(String urlTemplate, MultiValueMap params) throws Exception { + MockHttpServletRequestBuilder getRequest = get(urlTemplate).params(params); + setJwtToken(getRequest); + return mockMvc.perform(asyncDispatch(mockMvc.perform(getRequest).andExpect(request().asyncStarted()).andReturn())); + } + protected ResultActions doGet(String urlTemplate, Object... urlVariables) throws Exception { MockHttpServletRequestBuilder getRequest = get(urlTemplate, urlVariables); setJwtToken(getRequest); @@ -743,6 +792,12 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return mockMvc.perform(getRequest); } + protected ResultActions doGetWithApiKey(String urlTemplate, Object... urlVariables) throws Exception { + MockHttpServletRequestBuilder getRequest = get(urlTemplate, urlVariables); + setApiKey(getRequest); + return mockMvc.perform(getRequest); + } + protected T doGet(String urlTemplate, Class responseClass, Object... urlVariables) throws Exception { return readResponse(doGet(urlTemplate, urlVariables).andExpect(status().isOk()), responseClass); } @@ -766,6 +821,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return mockMvc.perform(asyncDispatch(mockMvc.perform(getRequest).andExpect(request().asyncStarted()).andReturn())); } + protected T doGetWithApiKey(String urlTemplate, Class responseClass, Object... urlVariables) throws Exception { + return readResponse(doGetWithApiKey(urlTemplate, urlVariables).andExpect(status().isOk()), responseClass); + } + protected T doGetTyped(String urlTemplate, TypeReference responseType, Object... urlVariables) throws Exception { return readResponse(doGet(urlTemplate, urlVariables).andExpect(status().isOk()), responseType); } @@ -845,6 +904,14 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { } } + protected R doPostWithApiKey(String urlTemplate, T content, Class responseClass, String... params) { + try { + return readResponse(doPostWithApiKey(urlTemplate, content, params).andExpect(status().isOk()), responseClass); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + protected R doPostWithResponse(String urlTemplate, T content, Class responseClass, String... params) throws Exception { return readResponse(doPost(urlTemplate, content, params).andExpect(status().isOk()), responseClass); } @@ -912,6 +979,14 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return mockMvc.perform(postRequest); } + protected ResultActions doPostWithApiKey(String urlTemplate, T content, String... params) throws Exception { + MockHttpServletRequestBuilder postRequest = post(urlTemplate, params); + setApiKey(postRequest); + String json = json(content); + postRequest.contentType(contentType).content(json); + return mockMvc.perform(postRequest); + } + protected ResultActions doPostAsync(String urlTemplate, T content, Long timeout, String... params) throws Exception { MockHttpServletRequestBuilder postRequest = post(urlTemplate, params); setJwtToken(postRequest); @@ -929,6 +1004,22 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return mockMvc.perform(deleteRequest); } + protected ResultActions doDeleteAsync(String urlTemplate, MultiValueMap params) throws Exception { + MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate) + .params(params); + setJwtToken(deleteRequest); + MvcResult result = mockMvc.perform(deleteRequest).andReturn(); + result.getAsyncResult(DEFAULT_TIMEOUT); + return mockMvc.perform(asyncDispatch(result)); + } + + protected ResultActions doDeleteWithApiKey(String urlTemplate, String... params) throws Exception { + MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate); + setApiKey(deleteRequest); + populateParams(deleteRequest, params); + return mockMvc.perform(deleteRequest); + } + protected ResultActions doDeleteAsync(String urlTemplate, Long timeout, String... params) throws Exception { MockHttpServletRequestBuilder deleteRequest = delete(urlTemplate, params); setJwtToken(deleteRequest); @@ -1052,7 +1143,17 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected void createEntityRelation(EntityId entityIdFrom, EntityId entityIdTo, String typeRelation) throws Exception { EntityRelation relation = new EntityRelation(entityIdFrom, entityIdTo, typeRelation); - doPost("/api/relation", relation); + doPost("/api/relation", relation).andExpect(status().isOk()); + } + + protected void deleteEntityRelation(EntityRelation entityRelation) throws Exception { + String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + entityRelation.getFrom().getId(), + entityRelation.getFrom().getEntityType(), + entityRelation.getType(), + entityRelation.getTo().getId(), + entityRelation.getTo().getEntityType()); + doDelete(url); } protected List findRelationsByTo(EntityId entityId) throws Exception { @@ -1103,6 +1204,18 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { }); } + protected void awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsReadyToRefreshDynamicArguments(EntityId entityId, CalculatedFieldId cfId, int scheduledUpdateInterval) { + CalculatedFieldEntityMessageProcessor processor = getCalculatedFieldEntityMessageProcessor(entityId); + Map statesMap = (Map) ReflectionTestUtils.getField(processor, "states"); + Awaitility.await("CF state for entity actor ready to refresh dynamic arguments").atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> { + CalculatedFieldState calculatedFieldState = statesMap.get(cfId); + boolean isReady = calculatedFieldState != null && ((GeofencingCalculatedFieldState) calculatedFieldState).getLastScheduledRefreshTs() < + System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(scheduledUpdateInterval); + log.warn("entityId {}, cfId {}, state ready to refresh == {}", entityId, cfId, isReady); + return isReady; + }); + } + protected static String getMapName(FeatureType featureType) { switch (featureType) { case ATTRIBUTES: @@ -1124,6 +1237,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(); @@ -1278,7 +1401,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected List findJobs(List types, List entities) throws Exception { return doGetTypedWithPageLink("/api/jobs?types=" + types.stream().map(Enum::name).collect(Collectors.joining(",")) + - "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&", + "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&", new TypeReference>() {}, new PageLink(100, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC))).getData(); } @@ -1290,4 +1413,52 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { doPost("/api/job/" + jobId + "/reprocess").andExpect(status().isOk()); } + protected void postTelemetry(EntityId entityId, String payload) throws Exception { + doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); + } + + protected void postAttributes(EntityId entityId, AttributeScope scope, String payload) throws Exception { + doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + + "/attributes/" + scope, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); + } + + protected CalculatedField saveCalculatedField(CalculatedField calculatedField) { + return doPost("/api/calculatedField", calculatedField, CalculatedField.class); + } + + protected PageData getEntityCalculatedFields(EntityId entityId, CalculatedFieldType type, PageLink pageLink) throws Exception { + return doGetTypedWithPageLink("/api/" + entityId.getEntityType() + "/" + entityId.getId() + "/calculatedFields" + + (type != null ? "?type=" + type.name() + "&" : "?"), new TypeReference<>() {}, pageLink); + } + + protected PageData getCalculatedFieldNames(CalculatedFieldType type, PageLink pageLink) throws Exception { + return doGetTypedWithPageLink("/api/calculatedFields/names?type=" + type + "&", + new TypeReference>() {}, pageLink); + } + + protected List getCalculatedFields(CalculatedFieldType type, + EntityType entityType, + List entities, + List names) throws Exception { + return doGetTypedWithPageLink("/api/calculatedFields?" + + (type != null ? "types=" + type + "&" : "") + + (entityType != null ? "entityType=" + entityType + "&" : "") + + (entities != null ? "entities=" + String.join(",", + entities.stream().map(UUID::toString).toList()) + "&" : "") + + (names != null ? names.stream().map(name -> "name=" + name + "&").collect(Collectors.joining("")) : ""), + new TypeReference>() {}, new PageLink(10)).getData(); + } + + protected PageData getDebugEvents(TenantId tenantId, EntityId entityId, int limit) throws Exception { + return getEvents(tenantId, entityId, EventType.DEBUG_RULE_NODE, limit); + } + + protected PageData getEvents(TenantId tenantId, EntityId entityId, EventType eventType, int limit) throws Exception { + TimePageLink pageLink = new TimePageLink(limit); + return doGetTypedWithTimePageLink("/api/events/{entityType}/{entityId}/{eventType}?tenantId={tenantId}&", + new TypeReference>() { + }, pageLink, entityId.getEntityType(), entityId.getId(), eventType, tenantId.getId()); + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java index 245993b51e..21a8b3b2e9 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java index 53648af441..099ef05752 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java index 0dcc037b98..17a30cac35 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -48,6 +48,7 @@ import java.util.List; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.common.data.alarm.AlarmCommentSubType.COMMENT_DELETED; @Slf4j @ContextConfiguration(classes = {AlarmCommentControllerTest.Config.class}) @@ -207,8 +208,10 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { AlarmComment expectedAlarmComment = AlarmComment.builder() .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) - .comment(JacksonUtil.newObjectNode().put("text", String.format("User %s deleted his comment", - CUSTOMER_USER_EMAIL))) + .comment(JacksonUtil.newObjectNode() + .put("text", String.format(COMMENT_DELETED.getText(), CUSTOMER_USER_EMAIL)) + .put("subtype", COMMENT_DELETED.name()) + .put("userName", CUSTOMER_USER_EMAIL)) .build(); testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); } @@ -226,8 +229,10 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { AlarmComment expectedAlarmComment = AlarmComment.builder() .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) - .comment(JacksonUtil.newObjectNode().put("text", String.format("User %s deleted his comment", - TENANT_ADMIN_EMAIL))) + .comment(JacksonUtil.newObjectNode() + .put("text", String.format(COMMENT_DELETED.getText(), TENANT_ADMIN_EMAIL)) + .put("subtype", COMMENT_DELETED.name()) + .put("userName", TENANT_ADMIN_EMAIL)) .build(); testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); } diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmControllerTest.java index 8a49d34d20..81e2ff0311 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AlarmControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java new file mode 100644 index 0000000000..9c7f7a898d --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java @@ -0,0 +1,144 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.UUID; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class ApiKeyControllerTest extends AbstractControllerTest { + + @Before + public void setUp() throws Exception { + loginTenantAdmin(); + } + + @Test + public void testSaveApiKey() throws Exception { + ApiKeyInfo apiKeyInfo = constructApiKeyInfo("New API key description", true); + + doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + + PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); + Assert.assertEquals(1, pageData.getData().size()); + + ApiKeyInfo savedApiKey = pageData.getData().get(0); + Assert.assertNotNull(savedApiKey); + Assert.assertEquals(apiKeyInfo.getDescription(), savedApiKey.getDescription()); + Assert.assertEquals(apiKeyInfo.isEnabled(), savedApiKey.isEnabled()); + Assert.assertEquals(tenantId, savedApiKey.getTenantId()); + Assert.assertEquals(tenantAdminUser.getId(), savedApiKey.getUserId()); + + doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk()); + } + + @Test + public void tesFindUserApiKeys() throws Exception { + PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); + Assert.assertTrue(pageData.getData().isEmpty()); + + ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true); + int expectedSize = 10; + for (int i = 0; i < expectedSize; i++) { + doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + } + + PageData pageData2 = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); + Assert.assertEquals(expectedSize, pageData2.getData().size()); + + pageData2.getData().forEach(apiKey -> { + try { + doDelete("/api/apiKey/" + apiKey.getId()).andExpect(status().isOk()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + @Test + public void testUpdateApiKeyDescription() throws Exception { + ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true); + doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + + PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); + Assert.assertEquals(1, pageData.getData().size()); + + ApiKeyInfo savedApiKey = pageData.getData().get(0); + + String newDescription = "Updated API Key Description"; + + ApiKeyInfo updatedApiKeyInfo = doPut("/api/apiKey/" + savedApiKey.getId().getId() + "/description", newDescription, ApiKeyInfo.class); + Assert.assertNotNull(updatedApiKeyInfo); + Assert.assertEquals(newDescription, updatedApiKeyInfo.getDescription()); + + doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk()); + } + + @Test + public void testEnableApiKey() throws Exception { + ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", true); + doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + + PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); + Assert.assertEquals(1, pageData.getData().size()); + + ApiKeyInfo savedApiKey = pageData.getData().get(0); + + ApiKeyInfo disabledApiKeyInfo = doPut("/api/apiKey/" + savedApiKey.getId().getId() + "/enabled/false", Boolean.FALSE, ApiKeyInfo.class); + Assert.assertNotNull(disabledApiKeyInfo); + Assert.assertFalse(disabledApiKeyInfo.isEnabled()); + + ApiKeyInfo enabledApiKeyInfo = doPut("/api/apiKey/" + savedApiKey.getId().getId() + "/enabled/true", Boolean.TRUE, ApiKeyInfo.class); + Assert.assertNotNull(enabledApiKeyInfo); + Assert.assertTrue(enabledApiKeyInfo.isEnabled()); + + doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk()); + } + + @Test + public void testDeleteApiKey() throws Exception { + doDelete("/api/apiKey/" + UUID.randomUUID()).andExpect(status().isNotFound()); + + ApiKeyInfo apiKeyInfo = constructApiKeyInfo("Test API key description", false); + doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + + PageData pageData = doGetTypedWithPageLink("/api/apiKeys/" + tenantAdminUserId + "?", new TypeReference<>() {}, new PageLink(10, 0)); + Assert.assertEquals(1, pageData.getData().size()); + ApiKeyInfo savedApiKey = pageData.getData().get(0); + + doDelete("/api/apiKey/" + savedApiKey.getId().getId()).andExpect(status().isOk()); + } + + private ApiKeyInfo constructApiKeyInfo(String description, boolean enabled) { + ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); + apiKeyInfo.setDescription(description); + apiKeyInfo.setEnabled(enabled); + apiKeyInfo.setUserId(tenantAdminUserId); + return apiKeyInfo; + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java index 90b5cbeef2..98b06ebbd1 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -50,7 +50,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.asset.AssetDao; -import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.service.DaoSqlTest; @@ -1080,6 +1080,30 @@ public class AssetControllerTest extends AbstractControllerTest { testEntityDaoWithRelationsTransactionalException(assetDao, savedTenant.getId(), assetId, "/api/asset/" + assetId); } + @Test + public void testSaveAssetWithUniquifyStrategy() throws Exception { + Asset asset = new Asset(); + asset.setName("My unique asset"); + asset.setType("default"); + doPost("/api/asset", asset, Asset.class); + + doPost("/api/asset", asset).andExpect(status().isBadRequest()); + + doPost("/api/asset?nameConflictPolicy=FAIL", asset).andExpect(status().isBadRequest()); + + Asset secondAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY", asset, Asset.class); + assertThat(secondAsset.getName()).startsWith("My unique asset_"); + + Asset thirdAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", asset, Asset.class); + assertThat(thirdAsset.getName()).startsWith("My unique asset-"); + + Asset fourthAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", asset, Asset.class); + assertThat(fourthAsset.getName()).isEqualTo("My unique asset_1"); + + Asset fifthAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", asset, Asset.class); + assertThat(fifthAsset.getName()).isEqualTo("My unique asset_2"); + } + private Asset createAsset(String name) { Asset asset = new Asset(); asset.setName(name); diff --git a/application/src/test/java/org/thingsboard/server/controller/AssetProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AssetProfileControllerTest.java index 2691352d01..490973f66c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AssetProfileControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AssetProfileControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -42,13 +42,17 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.asset.AssetProfileDao; -import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; import java.util.stream.Collectors; import static org.hamcrest.Matchers.containsString; @@ -195,6 +199,38 @@ public class AssetProfileControllerTest extends AbstractControllerTest { Assert.assertEquals(savedAssetProfile.getName(), foundAssetProfileInfo.getName()); } + @Test + public void testFindAssetProfileInfoByIds() throws Exception { + List assetProfiles = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + AssetProfile assetProfile = this.createAssetProfile("Asset Profile" + i); + assetProfile.setTenantId(savedTenant.getId()); + assetProfiles.add(doPost("/api/assetProfile", assetProfile, AssetProfileInfo.class)); + } + + List expected = assetProfiles.subList(5, 15); + + String idsParam = expected.stream() + .map(ap -> ap.getId().getId().toString()) + .collect(Collectors.joining(",")); + AssetProfileInfo[] foundAssetProfileInfos = doGet("/api/assetProfileInfos?assetProfileIds=" + idsParam, AssetProfileInfo[].class); + + Assert.assertNotNull(foundAssetProfileInfos); + Assert.assertEquals(expected.size(), foundAssetProfileInfos.length); + + Map infoById = Arrays.stream(foundAssetProfileInfos) + .collect(Collectors.toMap(info -> info.getId().getId(), Function.identity())); + + for (AssetProfileInfo assetProfileInfo : expected) { + UUID id = assetProfileInfo.getId().getId(); + AssetProfileInfo info = infoById.get(id); + Assert.assertNotNull("AssetProfileInfo not found for id " + id, info); + + Assert.assertEquals(assetProfileInfo.getId(), info.getId()); + Assert.assertEquals(assetProfileInfo.getName(), info.getName()); + } + } + @Test public void whenGetAssetProfileInfoById_thenPermissionsAreChecked() throws Exception { AssetProfile assetProfile = createAssetProfile("Asset profile 1"); diff --git a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java index c5c90f55b3..a76da5f161 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest_DedicatedEventsDataSource.java b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest_DedicatedEventsDataSource.java index e9f0036415..c594beae98 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest_DedicatedEventsDataSource.java +++ b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest_DedicatedEventsDataSource.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java index 9e707f426e..9126c42f2f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java index b0a3518e60..0d79882a94 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,8 +24,8 @@ import org.junit.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.data.DataConstants; @@ -96,15 +96,15 @@ public class BaseQueueControllerTest extends AbstractControllerTest { private RuleEngineStatisticsService ruleEngineStatisticsService; @Autowired private StatsFactory statsFactory; - @SpyBean - private TimeseriesDao timeseriesDao; @Autowired private QueueStatsService queueStatsService; - @SpyBean + @MockitoSpyBean + private TimeseriesDao timeseriesDao; + @MockitoSpyBean private PartitionService partitionService; - @SpyBean + @MockitoSpyBean private TimeseriesService timeseriesService; - @SpyBean + @MockitoSpyBean private ActorSystemContext actorSystemContext; @Test diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java index af43b34558..d9ec187e39 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,25 +19,48 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldInfo; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; -import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; -import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.HourInterval; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark; +import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; +import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.SortOrder; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.service.DaoSqlTest; +import java.util.Comparator; +import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS; @DaoSqlTest public class CalculatedFieldControllerTest extends AbstractControllerTest { @@ -73,7 +96,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { @Test public void testSaveCalculatedField() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - CalculatedField calculatedField = getCalculatedField(testDevice.getId()); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); @@ -84,7 +107,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId()); assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); - assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getCalculatedFieldConfig()); + assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getSimpleCalculatedFieldConfig()); assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); savedCalculatedField.setName("Test CF"); @@ -98,10 +121,104 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { .andExpect(status().isOk()); } + @Test + public void testSaveGeofencingCalculatedField() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.GEOFENCING); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + assertThat(savedCalculatedField).isNotNull(); + assertThat(savedCalculatedField.getId()).isNotNull(); + assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0); + assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId()); + assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId()); + assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); + assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); + assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getGeofencingCalculatedFieldConfig()); + assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); + + savedCalculatedField.setName("Test CF"); + + CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); + + assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName()); + assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); + + doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSavePropagationCalculatedField() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.PROPAGATION); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + assertThat(savedCalculatedField).isNotNull(); + assertThat(savedCalculatedField.getId()).isNotNull(); + assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0); + assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId()); + assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId()); + assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); + assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); + assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getPropagationCalculatedFieldConfig()); + assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); + + savedCalculatedField.setName("Test CF"); + + CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); + + assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName()); + assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); + + doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSaveEntityAggregationCalculatedField() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.ENTITY_AGGREGATION); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + assertThat(savedCalculatedField).isNotNull(); + assertThat(savedCalculatedField.getId()).isNotNull(); + assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0); + assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId()); + assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId()); + assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); + assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); + assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getEntityAggregationCalculatedFieldConfig()); + assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); + + savedCalculatedField.setName("Test CF"); + + CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); + + assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName()); + assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); + + doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSavePropagationCalculatedFieldWithNullArguments() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.PROPAGATION, getPropagationCalculatedFieldConfig(null)); + + doPost("/api/calculatedField", calculatedField) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("arguments must not be empty"))); + } + @Test public void testGetCalculatedFieldById() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - CalculatedField calculatedField = getCalculatedField(testDevice.getId()); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); CalculatedField fetchedCalculatedField = doGet("/api/calculatedField/" + savedCalculatedField.getId().getId(), CalculatedField.class); @@ -113,10 +230,74 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { .andExpect(status().isOk()); } + @Test + public void testGetEntityCalculatedFields() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); + calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + assertThat(getEntityCalculatedFields(testDevice.getId(), null, new PageLink(10)).getData()) + .singleElement().isEqualTo(calculatedField); + assertThat(getEntityCalculatedFields(testDevice.getId(), CalculatedFieldType.SIMPLE, new PageLink(10)).getData()) + .singleElement().isEqualTo(calculatedField); + } + + @Test + public void testGetCalculatedFieldsByFilter() throws Exception { + Device device = createDevice("Device A", "1234567890"); + CalculatedFieldInfo deviceCalculatedField = new CalculatedFieldInfo( + doPost("/api/calculatedField", getSimpleCalculatedField(device.getId()), CalculatedField.class), + "Device A" + ); + + DeviceProfile deviceProfile = doPost("/api/deviceProfile", createDeviceProfile("Profile A"), DeviceProfile.class); + CalculatedFieldInfo profileCalculatedField = new CalculatedFieldInfo( + doPost("/api/calculatedField", getSimpleCalculatedField(deviceProfile.getId()), CalculatedField.class), + "Profile A" + ); + + List allCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, + null, null, null); + assertThat(allCalculatedFields).contains(deviceCalculatedField, profileCalculatedField); + allCalculatedFields = getCalculatedFields(null, null, null, null); + assertThat(allCalculatedFields).contains(deviceCalculatedField, profileCalculatedField); + + List profileLevelCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, + EntityType.DEVICE_PROFILE, null, null); + assertThat(profileLevelCalculatedFields).containsOnly(profileCalculatedField); + + List specificDeviceCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, + EntityType.DEVICE, List.of(device.getUuidId()), null); + assertThat(specificDeviceCalculatedFields).containsOnly(deviceCalculatedField); + + List byNameCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, + null, null, List.of(deviceCalculatedField.getName())); + assertThat(byNameCalculatedFields).containsOnly(deviceCalculatedField); + + byNameCalculatedFields = getCalculatedFields(CalculatedFieldType.SIMPLE, null, null, + List.of(deviceCalculatedField.getName(), profileCalculatedField.getName())); + assertThat(byNameCalculatedFields).contains(deviceCalculatedField, profileCalculatedField); + + PageData names = getCalculatedFieldNames(CalculatedFieldType.SIMPLE, new PageLink(10, 0, + null, new SortOrder("", SortOrder.Direction.ASC))); + assertThat(names.getTotalElements()).isEqualTo(2); + assertThat(names.getData()).isSortedAccordingTo(Comparator.naturalOrder()); + assertThat(names.getData()).contains(deviceCalculatedField.getName(), profileCalculatedField.getName()); + + names = getCalculatedFieldNames(CalculatedFieldType.SIMPLE, new PageLink(10, 0, + null, new SortOrder("", SortOrder.Direction.DESC))); + assertThat(names.getData()).isSortedAccordingTo(Comparator.reverseOrder()); + + names = getCalculatedFieldNames(CalculatedFieldType.SIMPLE, new PageLink(10, 0, + device.getId().toString(), new SortOrder("", SortOrder.Direction.DESC))); + assertThat(names.getTotalElements()).isEqualTo(1); + assertThat(names.getData()).containsOnly(deviceCalculatedField.getName()); + } + @Test public void testDeleteCalculatedField() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - CalculatedField calculatedField = getCalculatedField(testDevice.getId()); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); @@ -127,18 +308,92 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { doGet("/api/calculatedField/" + savedCalculatedField.getId().getId()).andExpect(status().isNotFound()); } - private CalculatedField getCalculatedField(DeviceId deviceId) { + private CalculatedField getSimpleCalculatedField(EntityId entityId) { + return getCalculatedField(entityId, CalculatedFieldType.SIMPLE); + } + + private CalculatedField getCalculatedField(EntityId entityId, CalculatedFieldType cfType) { + return getCalculatedField(entityId, cfType, null); + } + + private CalculatedField getCalculatedField(EntityId entityId, CalculatedFieldType cfType, CalculatedFieldConfiguration customConfiguration) { CalculatedField calculatedField = new CalculatedField(); - calculatedField.setEntityId(deviceId); - calculatedField.setType(CalculatedFieldType.SIMPLE); - calculatedField.setName("Test Calculated Field"); + calculatedField.setEntityId(entityId); + calculatedField.setType(cfType); + calculatedField.setName("Test Calculated Field for " + entityId); calculatedField.setConfigurationVersion(1); - calculatedField.setConfiguration(getCalculatedFieldConfig()); + if (customConfiguration != null) { + calculatedField.setConfiguration(customConfiguration); + } else switch (cfType) { + case SIMPLE -> calculatedField.setConfiguration(getSimpleCalculatedFieldConfig()); + case GEOFENCING -> calculatedField.setConfiguration(getGeofencingCalculatedFieldConfig()); + case PROPAGATION -> calculatedField.setConfiguration(getPropagationCalculatedFieldConfig()); + case ENTITY_AGGREGATION -> calculatedField.setConfiguration(getEntityAggregationCalculatedFieldConfig()); + } calculatedField.setVersion(1L); return calculatedField; } - private CalculatedFieldConfiguration getCalculatedFieldConfig() { + private CalculatedFieldConfiguration getGeofencingCalculatedFieldConfig() { + var config = new GeofencingCalculatedFieldConfiguration(); + + var refDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + refDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "FromSafeArea"))); + + var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + zoneGroupConfiguration.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration); + + config.setEntityCoordinates(new EntityCoordinates("latitide", "longitude")); + config.setZoneGroups(Map.of("safeArea", zoneGroupConfiguration)); + config.setScheduledUpdateEnabled(false); + config.setOutput(new TimeSeriesOutput()); + + return config; + } + + private CalculatedFieldConfiguration getPropagationCalculatedFieldConfig() { + Argument arg = new Argument(); + arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + return getPropagationCalculatedFieldConfig(Map.of("t", arg)); + } + + private CalculatedFieldConfiguration getPropagationCalculatedFieldConfig(Map arguments) { + var config = new PropagationCalculatedFieldConfiguration(); + + config.setRelation(new RelationPathLevel(EntitySearchDirection.TO, EntityRelation.CONTAINS_TYPE)); + + config.setApplyExpressionToResolvedArguments(false); + config.setExpression(null); + config.setOutput(new TimeSeriesOutput()); + + Argument arg = new Argument(); + arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + config.setArguments(arguments); + + return config; + } + + private CalculatedFieldConfiguration getEntityAggregationCalculatedFieldConfig() { + var config = new EntityAggregationCalculatedFieldConfiguration(); + + Argument energyArgument = new Argument(); + energyArgument.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null)); + config.setArguments(Map.of("en", energyArgument)); + + AggMetric metric = new AggMetric(); + metric.setInput(new AggKeyInput("en")); + metric.setDefaultValue(9999.0); + config.setMetrics(Map.of("consumption", metric)); + + config.setWatermark(new Watermark(TimeUnit.DAYS.toSeconds(1))); + config.setInterval(new HourInterval("Europe/Kiev", TimeUnit.MINUTES.toSeconds(15))); + + config.setOutput(new TimeSeriesOutput()); + + return config; + } + + private CalculatedFieldConfiguration getSimpleCalculatedFieldConfig() { SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); Argument argument = new Argument(); @@ -150,9 +405,8 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { config.setExpression("T - (100 - H) / 5"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("output"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); diff --git a/application/src/test/java/org/thingsboard/server/controller/ComponentDescriptorControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ComponentDescriptorControllerTest.java index c29bbe6375..bfa796d8f3 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ComponentDescriptorControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/ComponentDescriptorControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java index f312e49f71..dd3ab07ba0 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33,6 +33,7 @@ import org.springframework.context.annotation.Primary; import org.springframework.test.context.ContextConfiguration; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; @@ -43,12 +44,17 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.customer.CustomerDao; -import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Map; +import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; @@ -260,6 +266,40 @@ public class CustomerControllerTest extends AbstractControllerTest { .andExpect(status().isOk()); } + @Test + public void testFindCustomersByIds() throws Exception { + List savedCustomers = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Customer customer = new Customer(); + customer.setTitle("My customer " + i); + savedCustomers.add(doPost("/api/customer", customer, Customer.class)); + } + + String idsParam = savedCustomers.stream() + .map(c -> c.getId().getId().toString()) + .collect(Collectors.joining(",")); + + Customer[] foundCustomers = doGet("/api/customers?customerIds=" + idsParam, Customer[].class); + + Assert.assertNotNull(foundCustomers); + Assert.assertEquals(savedCustomers.size(), foundCustomers.length); + + Map foundById = Arrays.stream(foundCustomers) + .collect(Collectors.toMap(c -> c.getId().getId(), Function.identity())); + + for (Customer savedCustomer : savedCustomers) { + UUID id = savedCustomer.getId().getId(); + Customer foundCustomer = foundById.get(id); + Assert.assertNotNull("Customer not found for id " + id, foundCustomer); + Assert.assertEquals(savedCustomer, foundCustomer); + } + + for (Customer savedCustomer : savedCustomers) { + doDelete("/api/customer/" + savedCustomer.getId().getId().toString()) + .andExpect(status().isOk()); + } + } + @Test public void testDeleteCustomer() throws Exception { Customer customer = new Customer(); @@ -462,6 +502,27 @@ public class CustomerControllerTest extends AbstractControllerTest { testEntityDaoWithRelationsTransactionalException(customerDao, savedTenant.getId(), customerId, "/api/customer/" + customerId); } + @Test + public void testSaveCustomerWithUniquifyStrategy() throws Exception { + Customer customer = new Customer(); + customer.setTitle("My unique customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + doPost("/api/customer?nameConflictPolicy=FAIL", customer).andExpect(status().isBadRequest()); + + Customer secondCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY", customer, Customer.class); + assertThat(secondCustomer.getName()).startsWith("My unique customer_"); + + Customer thirdCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", customer, Customer.class); + assertThat(thirdCustomer.getName()).startsWith("My unique customer-"); + + Customer fourthCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", customer, Customer.class); + assertThat(fourthCustomer.getName()).isEqualTo("My unique customer_1"); + + Customer fifthCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", customer, Customer.class); + assertThat(fifthCustomer.getName()).isEqualTo("My unique customer_2"); + } + private Customer createCustomer(String title) { Customer customer = new Customer(); customer.setTitle(title); diff --git a/application/src/test/java/org/thingsboard/server/controller/DashboardControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DashboardControllerTest.java index a73bc3befb..27fcabbd2c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DashboardControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DashboardControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,13 +51,16 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.dashboard.DashboardDao; -import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.UUID; +import java.util.function.Function; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -155,6 +158,42 @@ public class DashboardControllerTest extends AbstractControllerTest { Assert.assertEquals(savedDashboard, foundDashboard); } + @Test + public void testFindDashboardInfosByIds() throws Exception { + List dashboards = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + Dashboard dashboard = new Dashboard(); + dashboard.setTitle("My dashboard " + i); + dashboards.add(doPost("/api/dashboard", dashboard, Dashboard.class)); + } + + List expected = dashboards.subList(5, 15); + + String idsParam = expected.stream() + .map(d -> d.getId().getId().toString()) + .collect(Collectors.joining(",")); + + DashboardInfo[] result = doGet( + "/api/dashboards?dashboardIds=" + idsParam, + DashboardInfo[].class + ); + + Assert.assertNotNull(result); + Assert.assertEquals(expected.size(), result.length); + + Map infoById = Arrays.stream(result) + .collect(Collectors.toMap(info -> info.getId().getId(), Function.identity())); + + for (Dashboard dashboard : expected) { + UUID id = dashboard.getId().getId(); + DashboardInfo info = infoById.get(id); + Assert.assertNotNull("DashboardInfo not found for id " + id, info); + + Assert.assertEquals(dashboard.getId(), info.getId()); + Assert.assertEquals(dashboard.getTitle(), info.getTitle()); + } + } + @Test public void testDeleteDashboard() throws Exception { Dashboard dashboard = new Dashboard(); diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java index fdf54d0109..9a80ad2e25 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -95,7 +95,7 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest { private DeviceProfileId mqttDeviceProfileId; private DeviceProfileId coapDeviceProfileId; - @Value("${device.connectivity.gateway.image_version:3.7-stable}") + @Value("${device.connectivity.gateway.image_version:3.8-stable}") private String gatewayImageVersion; @Before @@ -321,9 +321,10 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest { "\n" + " # Environment variables\n" + " environment:\n" + - " - host=host.docker.internal\n" + - " - port=1883\n" + - " - accessToken=" + credentials.getCredentialsId() + "\n" + + " - TB_GW_HOST=host.docker.internal\n" + + " - TB_GW_PORT=1883\n" + + " - TB_GW_SECURITY_TYPE=accessToken\n" + + " - TB_GW_ACCESS_TOKEN=" + credentials.getCredentialsId() + "\n" + "\n" + " # Volumes bind\n" + " volumes:\n" + diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java index 4551e530d2..7332ee26c6 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -38,6 +38,7 @@ import org.springframework.test.context.ContextConfiguration; import org.testcontainers.shaded.org.awaitility.Awaitility; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceInfo; @@ -59,6 +60,7 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceCredentialsId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.relation.EntityRelation; @@ -70,15 +72,19 @@ import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportColumn import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportRequest; import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportResult; import org.thingsboard.server.dao.device.DeviceDao; -import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.service.gateway_device.GatewayNotificationsService; import org.thingsboard.server.service.state.DeviceStateService; +import org.thingsboard.server.utils.CsvUtils; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -1629,6 +1635,57 @@ public class DeviceControllerTest extends AbstractControllerTest { Assert.assertEquals(newAttributeValue, actualAttribute.get("value")); } + @Test + public void testBulkImportDeviceWithJsonAttr() throws Exception { + String deviceName = "some_device"; + String deviceType = "some_type"; + String deviceAttr = "{\"threshold\":45}"; + + List> content = new LinkedList<>(); + content.add(Arrays.asList("NAME", "TYPE", "ATTR")); + content.add(Arrays.asList(deviceName, deviceType, deviceAttr)); + + byte[] bytes = CsvUtils.generateCsv(content); + BulkImportRequest request = new BulkImportRequest(); + request.setFile(new String(bytes, StandardCharsets.UTF_8)); + BulkImportRequest.Mapping mapping = new BulkImportRequest.Mapping(); + BulkImportRequest.ColumnMapping name = new BulkImportRequest.ColumnMapping(); + name.setType(BulkImportColumnType.NAME); + BulkImportRequest.ColumnMapping type = new BulkImportRequest.ColumnMapping(); + type.setType(BulkImportColumnType.TYPE); + BulkImportRequest.ColumnMapping attr = new BulkImportRequest.ColumnMapping(); + attr.setType(BulkImportColumnType.SERVER_ATTRIBUTE); + attr.setKey("attr"); + List columns = new ArrayList<>(); + columns.add(name); + columns.add(type); + columns.add(attr); + + mapping.setColumns(columns); + mapping.setDelimiter(','); + mapping.setUpdate(true); + mapping.setHeader(true); + request.setMapping(mapping); + + BulkImportResult deviceBulkImportResult = doPostWithTypedResponse("/api/device/bulk_import", request, new TypeReference<>() {}); + + Assert.assertEquals(1, deviceBulkImportResult.getCreated().get()); + Assert.assertEquals(0, deviceBulkImportResult.getErrors().get()); + Assert.assertEquals(0, deviceBulkImportResult.getUpdated().get()); + Assert.assertTrue(deviceBulkImportResult.getErrorsList().isEmpty()); + + Device savedDevice = doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class); + + Assert.assertNotNull(savedDevice); + Assert.assertEquals(deviceName, savedDevice.getName()); + Assert.assertEquals(deviceType, savedDevice.getType()); + + Optional retrieved = await().atMost(5, TimeUnit.SECONDS) + .until(() -> attributesService.find(tenantId, savedDevice.getId(), AttributeScope.SERVER_SCOPE, "attr").get(), Optional::isPresent); + assertThat(retrieved.get().getJsonValue().get()).isEqualTo(deviceAttr); + assertThat(retrieved.get().getStrValue()).isNotPresent(); + } + @Test public void testSaveDeviceWithOutdatedVersion() throws Exception { Device device = createDevice("Device v1.0"); @@ -1651,6 +1708,30 @@ public class DeviceControllerTest extends AbstractControllerTest { assertThat(device.getVersion()).isEqualTo(3); } + @Test + public void testSaveDeviceWithUniquifyStrategy() throws Exception { + Device device = new Device(); + device.setName("My unique device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + + doPost("/api/device", device).andExpect(status().isBadRequest()); + + doPost("/api/device?nameConflictPolicy=FAIL", device).andExpect(status().isBadRequest()); + + Device secondDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY", device, Device.class); + assertThat(secondDevice.getName()).startsWith("My unique device_"); + + Device thirdDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", device, Device.class); + assertThat(thirdDevice.getName()).startsWith("My unique device-"); + + Device fourthDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", device, Device.class); + assertThat(fourthDevice.getName()).isEqualTo("My unique device_1"); + + Device fifthDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", device, Device.class); + assertThat(fifthDevice.getName()).isEqualTo("My unique device_2"); + } + private Device createDevice(String name) { Device device = new Device(); device.setName(name); diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java index 320e3815f6..3966bbb97a 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -52,13 +52,17 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.device.DeviceProfileDao; -import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -214,6 +218,42 @@ public class DeviceProfileControllerTest extends AbstractControllerTest { Assert.assertEquals(savedDeviceProfile.getType(), foundDeviceProfileInfo.getType()); } + @Test + public void testFindDeviceProfileInfosByIds() throws Exception { + List deviceProfiles = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile " + i); + deviceProfiles.add(saveDeviceProfile(deviceProfile)); + } + + List expected = deviceProfiles.subList(5, 15); + + String idsParam = expected.stream() + .map(dp -> dp.getId().getId().toString()) + .collect(Collectors.joining(",")); + + DeviceProfileInfo[] result = doGet( + "/api/deviceProfileInfos?deviceProfileIds=" + idsParam, + DeviceProfileInfo[].class + ); + + Assert.assertNotNull(result); + Assert.assertEquals(expected.size(), result.length); + + Map infoById = Arrays.stream(result) + .collect(Collectors.toMap(info -> info.getId().getId(), Function.identity())); + + for (DeviceProfile dp : expected) { + UUID id = dp.getId().getId(); + DeviceProfileInfo info = infoById.get(id); + Assert.assertNotNull("DeviceProfileInfo not found for id " + id, info); + + Assert.assertEquals(dp.getId(), info.getId()); + Assert.assertEquals(dp.getName(), info.getName()); + Assert.assertEquals(dp.getType(), info.getType()); + } + } + @Test public void whenGetDeviceProfileInfoById_thenPermissionsAreChecked() throws Exception { DeviceProfile deviceProfile = createDeviceProfile("Device profile 1", null); diff --git a/application/src/test/java/org/thingsboard/server/controller/DomainControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DomainControllerTest.java index 215a2490df..84b352b4c7 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DomainControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DomainControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java index f1d3ef1d52..43f08f42f9 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -72,7 +72,7 @@ import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.security.model.JwtSettings; import org.thingsboard.server.dao.edge.EdgeDao; -import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.edge.imitator.EdgeImitator; diff --git a/application/src/test/java/org/thingsboard/server/controller/EdgeEventControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdgeEventControllerTest.java index 38ca393fc3..ba389d3bd6 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EdgeEventControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EdgeEventControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/EdqsControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdqsControllerTest.java index 91be3f4744..fbd7d73421 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EdqsControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EdqsControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,7 @@ import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; @@ -41,6 +42,7 @@ import org.thingsboard.server.common.data.query.FilterPredicateValue; import org.thingsboard.server.common.data.query.KeyFilter; import org.thingsboard.server.common.data.query.StringFilterPredicate; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.edqs.util.EdqsRocksDb; import java.util.ArrayList; import java.util.Collections; @@ -62,8 +64,11 @@ public class EdqsControllerTest extends AbstractControllerTest { @Autowired private JdbcTemplate jdbcTemplate; + @MockitoBean + private EdqsRocksDb edqsRocksDb; + @Before - public void beforeEdqsControllerTest() throws Exception { + public void before() throws Exception { loginTenantAdmin(); } diff --git a/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java index 655ab417e6..f15ab09ab2 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,7 +19,6 @@ import org.assertj.core.api.ThrowingConsumer; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; import org.thingsboard.server.common.data.edqs.EdqsState; import org.thingsboard.server.common.data.edqs.EdqsState.EdqsApiMode; @@ -33,7 +32,6 @@ import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.msg.edqs.EdqsService; import org.thingsboard.server.dao.service.DaoSqlTest; -import org.thingsboard.server.edqs.util.EdqsRocksDb; import org.thingsboard.server.queue.discovery.DiscoveryService; import java.util.concurrent.TimeUnit; @@ -59,9 +57,6 @@ public class EdqsEntityQueryControllerTest extends EntityQueryControllerTest { @Autowired private DiscoveryService discoveryService; - @MockBean // so that we don't do backup for tests - private EdqsRocksDb edqsRocksDb; - @Before public void before() { await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> edqsService.getState().isApiEnabled()); diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index 93d67ccd4b..e1173ef9a7 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,15 +17,18 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.ResultActions; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.DataConstants; @@ -33,6 +36,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmSeverity; @@ -62,6 +66,7 @@ import org.thingsboard.server.common.data.query.FilterPredicateValue; import org.thingsboard.server.common.data.query.KeyFilter; import org.thingsboard.server.common.data.query.NumericFilterPredicate; import org.thingsboard.server.common.data.query.RelationsQueryFilter; +import org.thingsboard.server.common.data.query.SingleEntityFilter; import org.thingsboard.server.common.data.query.StringFilterPredicate; import org.thingsboard.server.common.data.query.TsValue; import org.thingsboard.server.common.data.queue.QueueStats; @@ -69,14 +74,18 @@ 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.security.Authority; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.edqs.util.EdqsRocksDb; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -101,6 +110,9 @@ public class EntityQueryControllerTest extends AbstractControllerTest { @Autowired private QueueStatsService queueStatsService; + @MockitoBean + private EdqsRocksDb edqsRocksDb; + @Before public void beforeTest() throws Exception { loginSysAdmin(); @@ -210,6 +222,64 @@ public class EntityQueryControllerTest extends AbstractControllerTest { countByQueryAndCheck(countQuery, 97); } + @Test + public void testEDQForSysAdmin() throws Exception { + loginSysAdmin(); + + ObjectNode tenantAttr = JacksonUtil.newObjectNode(); + tenantAttr.put("attr", "tenantAttrValue"); + doPost("/api/plugins/telemetry/TENANT/" + tenantId + "/attributes/" + AttributeScope.SERVER_SCOPE, tenantAttr); + + loginTenantAdmin(); + Device tenantDevice = new Device(); + tenantDevice.setName("device " + StringUtils.randomAlphanumeric(10)); + tenantDevice.setType("default"); + tenantDevice = doPost("/api/device", tenantDevice, Device.class); + + loginSysAdmin(); + TenantProfile tenantProfile = doPost("/api/tenantProfile", createTenantProfile("Test tenant profile"), TenantProfile.class); + + ObjectNode tenantProfileAttr = JacksonUtil.newObjectNode(); + tenantProfileAttr.put("attr", "tenantProfileAttrValue"); + doPost("/api/plugins/telemetry/TENANT_PROFILE/" + tenantProfile.getId() + "/attributes/" + AttributeScope.SERVER_SCOPE, tenantProfileAttr); + + // check tenant telemetry is accessible for sysadmin + SingleEntityFilter filter = new SingleEntityFilter(); + filter.setSingleEntity(AliasEntityId.fromEntityId(tenantId)); + EntityDataSortOrder sortOrder = new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"), EntityDataSortOrder.Direction.ASC); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); + List entityFields = List.of(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + List latestValues = List.of(new EntityKey(EntityKeyType.ATTRIBUTE, "attr")); + EntityDataQuery dataQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, null); + + PageData loadedTenants = findByQueryAndCheck(dataQuery, 1); + String retrievedTenantAttr = loadedTenants.getData().get(0).getLatest().get(EntityKeyType.ATTRIBUTE).get("attr").getValue(); + assertThat(retrievedTenantAttr).isEqualTo("tenantAttrValue"); + + // check tenant profile telemetry is accessible for sysadmin + filter.setSingleEntity(AliasEntityId.fromEntityId(tenantProfile.getId())); + + PageData loadedTenantProfiles = findByQueryAndCheck(dataQuery, 1); + String retrievedProfileAttr = loadedTenantProfiles.getData().get(0).getLatest().get(EntityKeyType.ATTRIBUTE).get("attr").getValue(); + assertThat(retrievedProfileAttr).isEqualTo("tenantProfileAttrValue"); + + // check other tenant entities are prohibited + filter.setSingleEntity(AliasEntityId.fromEntityId(tenantDevice.getId())); + findByQueryAndCheck(dataQuery, 0); + } + + private TenantProfile createTenantProfile(String name) { + TenantProfile tenantProfile = new TenantProfile(); + tenantProfile.setName(name); + tenantProfile.setDescription(name + " Test"); + TenantProfileData tenantProfileData = new TenantProfileData(); + tenantProfileData.setConfiguration(new DefaultTenantProfileConfiguration()); + tenantProfile.setProfileData(tenantProfileData); + tenantProfile.setDefault(false); + tenantProfile.setIsolatedTbRuleEngine(false); + return tenantProfile; + } + @Test public void testTenantCountAlarmsByQuery() throws Exception { loginTenantAdmin(); @@ -431,19 +501,22 @@ public class EntityQueryControllerTest extends AbstractControllerTest { List alarmFields = new ArrayList<>(); alarmFields.add(new EntityKey(EntityKeyType.ALARM_FIELD, "type")); + alarmFields.add(new EntityKey(EntityKeyType.ALARM_FIELD, "originatorDisplayName")); EntityTypeFilter assetTypeFilter = new EntityTypeFilter(); assetTypeFilter.setEntityType(EntityType.ASSET); - AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, alarmFields); + AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, alarmFields); PageData alarmPageData = findAlarmsByQueryAndCheck(assetAlarmQuery, 10); - List retrievedAlarmTypes = alarmPageData.getData().stream().map(Alarm::getType).toList(); + List retrievedAlarmTypes = alarmPageData.getData().stream().map(AlarmData::getType).toList(); assertThat(retrievedAlarmTypes).containsExactlyInAnyOrderElementsOf(assetAlarmTypes); + List retrievedAlarmDisplayName = alarmPageData.getData().stream().map(AlarmData::getOriginatorDisplayName).toList(); + assertThat(retrievedAlarmDisplayName).containsExactlyInAnyOrderElementsOf(assets.stream().map(Asset::getLabel).toList()); KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1"); List keyFilters = Collections.singletonList(nameFilter); - AlarmDataQuery filteredAssetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, keyFilters, alarmFields); - PageData filteredAssetAlamData = doPostWithTypedResponse("/api/alarmsQuery/find", filteredAssetAlarmQuery, new TypeReference<>() { + AlarmDataQuery filteredAssetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, keyFilters, alarmFields); + PageData filteredAssetAlamData = doPostWithTypedResponse("/api/alarmsQuery/find", filteredAssetAlarmQuery, new TypeReference<>() { }); Assert.assertEquals(1, filteredAssetAlamData.getTotalElements()); } @@ -505,16 +578,16 @@ public class EntityQueryControllerTest extends AbstractControllerTest { EntityTypeFilter assetTypeFilter = new EntityTypeFilter(); assetTypeFilter.setEntityType(EntityType.ASSET); - AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, Collections.emptyList()); + AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, Collections.emptyList()); - PageData alarmPageData = findAlarmsByQueryAndCheck(assetAlarmQuery, 10); + PageData alarmPageData = findAlarmsByQueryAndCheck(assetAlarmQuery, 10); List retrievedAlarmTypes = alarmPageData.getData().stream().map(Alarm::getType).toList(); assertThat(retrievedAlarmTypes).containsExactlyInAnyOrderElementsOf(assetAlarmTypes); KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1"); List keyFilters = Collections.singletonList(nameFilter); - AlarmDataQuery filteredAssetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, keyFilters, Collections.emptyList()); - PageData filteredAssetAlamData = doPostWithTypedResponse("/api/alarmsQuery/find", filteredAssetAlarmQuery, new TypeReference<>() { + AlarmDataQuery filteredAssetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, keyFilters, Collections.emptyList()); + PageData filteredAssetAlamData = doPostWithTypedResponse("/api/alarmsQuery/find", filteredAssetAlarmQuery, new TypeReference<>() { }); Assert.assertEquals(1, filteredAssetAlamData.getTotalElements()); } @@ -570,7 +643,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { EntityTypeFilter deviceTypeFilter = new EntityTypeFilter(); deviceTypeFilter.setEntityType(EntityType.DEVICE); - AlarmDataQuery deviceAlarmQuery = new AlarmDataQuery(deviceTypeFilter, pageLink, entityFields, latestValues, null, alarmFields); + AlarmDataQuery deviceAlarmQuery = new AlarmDataQuery(deviceTypeFilter, pageLink, entityFields, latestValues, null, alarmFields); PageData alarmPageData = findAlarmsByQueryAndCheck(deviceAlarmQuery, 10); List retrievedAlarmTemps = alarmPageData.getData().stream().map(alarmData -> alarmData.getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()).toList(); @@ -1068,6 +1141,119 @@ public class EntityQueryControllerTest extends AbstractControllerTest { countByQueryAndCheck(customerEntitiesQuery, 0); } + @Test + public void testFindDevicesByDisplayName() throws Exception { + loginTenantAdmin(); + int numOfDevices = 3; + + for (int i = 0; i < numOfDevices; i++) { + Device device = new Device(); + String name = "Device" + i; + device.setName(name); + device.setLabel("Device Label " + i); + device.setType("testFindDevicesByDisplayName"); + + Device savedDevice = doPost("/api/device?accessToken=" + name, device, Device.class); + } + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("testFindDevicesByDisplayName")); + filter.setDeviceNameFilter(""); + + KeyFilter displayNameFilter = getEntityFieldEqualFilter("displayName", "Device Label " + 0); + + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName"), EntityDataSortOrder.Direction.ASC + ); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); + List entityFields = List.of(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName")); + + // all devices with ownerName = TEST TENANT + EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), Collections.emptyList()); + checkEntitiesByQuery(query, numOfDevices, (i, entity) -> { + String name = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("name", new TsValue(0, "Invalid")).getValue(); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("Device" + i, name); + Assert.assertEquals("Device Label " + i, displayName); + }); + + // all devices with ownerName = TEST TENANT + EntityDataQuery displayNameFilterQuery = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(displayNameFilter)); + checkEntitiesByQuery(displayNameFilterQuery, 1, (i, entity) -> { + String name = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("name", new TsValue(0, "Invalid")).getValue(); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("Device" + i, name); + Assert.assertEquals("Device Label " + i, displayName); + }); + } + + @Test + public void testFindUsersByDisplayName() throws Exception { + loginTenantAdmin(); + + User userA = new User(); + userA.setAuthority(Authority.TENANT_ADMIN); + userA.setFirstName("John"); + userA.setLastName("Doe"); + userA.setEmail("john.doe@tb.org"); + userA = doPost("/api/user", userA, User.class); + var aId = userA.getId(); + + User userB = new User(); + userB.setAuthority(Authority.TENANT_ADMIN); + userB.setFirstName("John"); + userB.setEmail("john@tb.org"); + userB = doPost("/api/user", userB, User.class); + var bId = userB.getId(); + + User userC = new User(); + userC.setAuthority(Authority.TENANT_ADMIN); + userC.setLastName("Doe"); + userC.setEmail("doe@tb.org"); + userC = doPost("/api/user", userC, User.class); + var cId = userC.getId(); + + User userD = new User(); + userD.setAuthority(Authority.TENANT_ADMIN); + userD.setEmail("noname@tb.org"); + userD = doPost("/api/user", userD, User.class); + var dId = userD.getId(); + + EntityTypeFilter filter = new EntityTypeFilter(); + filter.setEntityType(EntityType.USER); + + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName"), EntityDataSortOrder.Direction.ASC + ); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); + List entityFields = List.of(new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName")); + + EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "John Doe"))); + checkEntitiesByQuery(query, 1, (i, entity) -> { + Assert.assertEquals(aId, entity.getEntityId()); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("John Doe", displayName); + }); + query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "John"))); + checkEntitiesByQuery(query, 1, (i, entity) -> { + Assert.assertEquals(bId, entity.getEntityId()); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("John", displayName); + }); + query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "Doe"))); + checkEntitiesByQuery(query, 1, (i, entity) -> { + Assert.assertEquals(cId, entity.getEntityId()); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("Doe", displayName); + }); + query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "noname@tb.org"))); + checkEntitiesByQuery(query, 1, (i, entity) -> { + Assert.assertEquals(dId, entity.getEntityId()); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("noname@tb.org", displayName); + }); + } + @Test public void testFindDevicesByOwnerNameAndOwnerType() throws Exception { loginTenantAdmin(); @@ -1105,19 +1291,30 @@ public class EntityQueryControllerTest extends AbstractControllerTest { // all devices with ownerName = TEST TENANT EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, tenantOwnerNameFilter)); - checkEntitiesByQuery(query, numOfDevices, TEST_TENANT_NAME, "TENANT"); + BiConsumer checkFunction = (i, entity) -> { + String name = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("name", new TsValue(0, "Invalid")).getValue(); + String ownerName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("ownerName", new TsValue(0, "Invalid")).getValue(); + String ownerType = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("ownerType", new TsValue(0, "Invalid")).getValue(); + String alarmActiveTime = entity.getLatest().get(EntityKeyType.ATTRIBUTE).getOrDefault("alarmActiveTime", new TsValue(0, "-1")).getValue(); + + Assert.assertEquals("Device" + i, name); + Assert.assertEquals(TEST_TENANT_NAME, ownerName); + Assert.assertEquals("TENANT", ownerType); + Assert.assertEquals("1" + i, alarmActiveTime); + }; + checkEntitiesByQuery(query, numOfDevices, checkFunction); // all devices with wrong ownerName EntityDataQuery wrongTenantNameQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, wrongOwnerNameFilter)); - checkEntitiesByQuery(wrongTenantNameQuery, 0, null, null); + checkEntitiesByQuery(wrongTenantNameQuery, 0, null); // all devices with owner type = TENANT EntityDataQuery tenantEntitiesQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, tenantOwnerTypeFilter)); - checkEntitiesByQuery(tenantEntitiesQuery, numOfDevices, TEST_TENANT_NAME, "TENANT"); + checkEntitiesByQuery(tenantEntitiesQuery, numOfDevices, checkFunction); // all devices with owner type = CUSTOMER EntityDataQuery customerEntitiesQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, customerOwnerTypeFilter)); - checkEntitiesByQuery(customerEntitiesQuery, 0, null, null); + checkEntitiesByQuery(customerEntitiesQuery, 0, null); } @Test @@ -1163,6 +1360,28 @@ public class EntityQueryControllerTest extends AbstractControllerTest { findByQueryAndCheck(query, 0); } + private void checkEntitiesByQuery(EntityDataQuery query, int expectedNumOfDevices, BiConsumer checkFunction) throws Exception { + await() + .alias("data by query") + .atMost(30, TimeUnit.SECONDS) + .until(() -> { + var data = findByQuery(query); + var loadedEntities = new ArrayList<>(data.getData()); + return loadedEntities.size() == expectedNumOfDevices; + }); + if (expectedNumOfDevices == 0) { + return; + } + var data = findByQuery(query); + var loadedEntities = new ArrayList<>(data.getData()); + + Assert.assertEquals(expectedNumOfDevices, loadedEntities.size()); + + for (int i = 0; i < expectedNumOfDevices; i++) { + checkFunction.accept(i, loadedEntities.get(i)); + } + } + private void checkEntitiesByQuery(EntityDataQuery query, int expectedNumOfDevices, String expectedOwnerName, String expectedOwnerType) throws Exception { await() .alias("data by query") diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java index 977877f8b9..b0c76a16a8 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,19 +17,15 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; -import lombok.extern.slf4j.Slf4j; -import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.ResultActions; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.EntityView; -import org.thingsboard.server.common.data.Tenant; -import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.relation.EntityRelation; @@ -39,8 +35,7 @@ import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.relation.RelationsSearchParameters; -import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.relation.RelationDao; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.Collections; @@ -48,57 +43,32 @@ import java.util.List; import java.util.UUID; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@Slf4j @DaoSqlTest public class EntityRelationControllerTest extends AbstractControllerTest { public static final String BASE_DEVICE_NAME = "Test dummy device"; @Autowired - RelationService relationService; + private RelationDao relationDao; - private IdComparator idComparator; - private Tenant savedTenant; - private User tenantAdmin; private Device mainDevice; @Before public void beforeTest() throws Exception { - loginSysAdmin(); - idComparator = new IdComparator<>(); + loginTenantAdmin(); - Tenant tenant = new Tenant(); - tenant.setTitle("Test tenant"); - - savedTenant = saveTenant(tenant); - Assert.assertNotNull(savedTenant); - - tenantAdmin = new User(); - tenantAdmin.setAuthority(Authority.TENANT_ADMIN); - tenantAdmin.setTenantId(savedTenant.getId()); - tenantAdmin.setEmail("tenant2@thingsboard.org"); - tenantAdmin.setFirstName("Joe"); - tenantAdmin.setLastName("Downs"); - tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); - - Device device = new Device(); + var device = new Device(); device.setName("Main test device"); device.setType("default"); mainDevice = doPost("/api/device", device, Device.class); } - @After - public void afterTest() throws Exception { - loginSysAdmin(); - - deleteTenant(savedTenant.getId()); - } - @Test public void testSaveAndFindRelation() throws Exception { - Device device = buildSimpleDevice("Test device 1"); + Device device = createDevice("Test device 1"); EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); Mockito.reset(tbClusterService, auditLogService); @@ -116,57 +86,117 @@ public class EntityRelationControllerTest extends AbstractControllerTest { Assert.assertEquals("Found relation is not equals origin!", relation, foundRelation); testNotifyEntityAllOneTimeRelation(foundRelation, - savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + tenantId, tenantAdminUser.getCustomerId(), tenantAdminUser.getId(), tenantAdminUser.getEmail(), ActionType.RELATION_ADD_OR_UPDATE, foundRelation); } @Test - public void testSaveWithDeviceFromNotCreated() throws Exception { - Device device = new Device(); - device.setName("Test device 2"); - device.setType("default"); - EntityRelation relation = createFromRelation(device, mainDevice, "CONTAINS"); - - Mockito.reset(tbClusterService, auditLogService); + public void testSaveRelationFromValidation() throws Exception { + // GIVEN + var relation = new EntityRelation(); + relation.setFrom(null); + relation.setTo(mainDevice.getId()); + relation.setType("Contains"); + + // WHEN-THEN + for (String endpoint : List.of("/api/relation", "/api/v2/relation")) { + doPost(endpoint, relation) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(is("Validation error: from must not be null"))); + } + } - doPost("/api/relation", relation) - .andExpect(status().isBadRequest()) - .andExpect(statusReason(containsString("Parameter entityId can't be empty!"))); + @Test + public void testSaveRelationToValidation() throws Exception { + // GIVEN + var relation = new EntityRelation(); + relation.setFrom(mainDevice.getId()); + relation.setTo(null); + relation.setType("Contains"); + + // WHEN-THEN + for (String endpoint : List.of("/api/relation", "/api/v2/relation")) { + doPost(endpoint, relation) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(is("Validation error: to must not be null"))); + } + } - testNotifyEntityNever(mainDevice.getId(), null); + @Test + public void testSaveRelationRelationTypeValidation() throws Exception { + // GIVEN + var device = createDevice("Test device"); + + EntityRelation relationTypeNull = createFromRelation(mainDevice, device, null); + EntityRelation relationTypeEmpty = createFromRelation(mainDevice, device, ""); + EntityRelation relationTypeBlank = createFromRelation(mainDevice, device, " "); + EntityRelation relationTypeContainsNullChar = createFromRelation(mainDevice, device, "null char \u0000"); + EntityRelation relationTypeTooLong = createFromRelation(mainDevice, device, "a".repeat(256)); + + // WHEN-THEN + for (String endpoint : List.of("/api/relation", "/api/v2/relation")) { + doPost(endpoint, relationTypeNull) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(is("Validation error: type must not be blank"))); + + doPost(endpoint, relationTypeEmpty) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(is("Validation error: type must not be blank"))); + + doPost(endpoint, relationTypeBlank) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(is("Validation error: type must not be blank"))); + + doPost(endpoint, relationTypeContainsNullChar) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(is("Validation error: type should not contain 0x00 symbol"))); + + doPost(endpoint, relationTypeTooLong) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(is("Validation error: type length must be equal or less than 255"))); + } } @Test - public void testSaveWithDeviceToNotCreated() throws Exception { - Device device = new Device(); - device.setName("Test device 2"); - device.setType("default"); - EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); + public void testSaveRelationFromNonexistentEntity() throws Exception { + // GIVEN + var nonexistentDevice = new Device(); + nonexistentDevice.setId(new DeviceId(UUID.randomUUID())); + nonexistentDevice.setName("Nonexistent device"); + nonexistentDevice.setType("default"); + EntityRelation relation = createFromRelation(nonexistentDevice, mainDevice, "CONTAINS"); Mockito.reset(tbClusterService, auditLogService); - doPost("/api/relation", relation) - .andExpect(status().isBadRequest()) - .andExpect(statusReason(containsString("Parameter entityId can't be empty!"))); + // WHEN-THEN + for (String endpoint : List.of("/api/relation", "/api/v2/relation")) { + doPost(endpoint, relation) + .andExpect(status().isNotFound()) + .andExpect(statusReason(is(msgErrorNoFound("Device", nonexistentDevice.getId().toString())))); - testNotifyEntityNever(mainDevice.getId(), null); + testNotifyEntityNever(mainDevice.getId(), null); + } } @Test - public void testSaveWithDeviceToMissing() throws Exception { - Device device = new Device(); - device.setName("Test device 2"); - device.setType("default"); - device.setId(new DeviceId(UUID.randomUUID())); - EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); + public void testSaveRelationToNonexistentEntity() throws Exception { + // GIVEN + var nonexistentDevice = new Device(); + nonexistentDevice.setId(new DeviceId(UUID.randomUUID())); + nonexistentDevice.setName("Nonexistent device"); + nonexistentDevice.setType("default"); + EntityRelation relation = createFromRelation(mainDevice, nonexistentDevice, "CONTAINS"); Mockito.reset(tbClusterService, auditLogService); - doPost("/api/relation", relation) - .andExpect(status().isNotFound()) - .andExpect(statusReason(containsString(msgErrorNoFound("Device", device.getId().getId().toString())))); + // WHEN-THEN + for (String endpoint : List.of("/api/relation", "/api/v2/relation")) { + doPost(endpoint, relation) + .andExpect(status().isNotFound()) + .andExpect(statusReason(is(msgErrorNoFound("Device", nonexistentDevice.getId().toString())))); - testNotifyEntityNever(mainDevice.getId(), null); + testNotifyEntityNever(mainDevice.getId(), null); + } } @Test @@ -178,7 +208,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest { createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME); EntityRelation relationTest = createFromRelation(mainDevice, mainDevice, "TEST_NOTIFY_ENTITY"); - testNotifyEntityAllManyRelation(relationTest, savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + testNotifyEntityAllManyRelation(relationTest, tenantId, tenantAdminUser.getCustomerId(), tenantAdminUser.getId(), tenantAdminUser.getEmail(), ActionType.RELATION_ADD_OR_UPDATE, numOfDevices); String url = String.format("/api/relations?fromId=%s&fromType=%s", @@ -204,7 +234,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest { final int numOfDevices = 30; createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME); - Device device = buildSimpleDevice("Unique dummy test device "); + Device device = createDevice("Unique dummy test device "); String relationType = "TEST"; EntityRelation relation = createFromRelation(mainDevice, device, relationType); @@ -221,7 +251,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest { final int numOfDevices = 30; createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME); - Device device = buildSimpleDevice("Unique dummy test device "); + Device device = createDevice("Unique dummy test device "); String relationType = "TEST"; EntityRelation relation = createFromRelation(mainDevice, device, relationType); @@ -240,7 +270,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest { final int numOfDevices = 30; createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME); - Device device = buildSimpleDevice("Unique dummy test device "); + Device device = createDevice("Unique dummy test device "); String relationType = "TEST"; EntityRelation relation = createFromRelation(device, mainDevice, relationType); @@ -252,13 +282,12 @@ public class EntityRelationControllerTest extends AbstractControllerTest { assertFoundList(url, 1); } - @Test public void testSaveAndFindRelationsByToWithRelationTypeOther() throws Exception { final int numOfDevices = 30; createDevicesByFrom(numOfDevices, BASE_DEVICE_NAME); - Device device = buildSimpleDevice("Unique dummy test device "); + Device device = createDevice("Unique dummy test device "); String relationType = "TEST"; EntityRelation relation = createFromRelation(device, mainDevice, relationType); @@ -280,9 +309,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest { mainDevice.getUuidId(), EntityType.DEVICE ); - List relationsInfos = - JacksonUtil.convertValue(doGet(url, JsonNode.class), new TypeReference<>() { - }); + List relationsInfos = JacksonUtil.convertValue(doGet(url, JsonNode.class), new TypeReference<>() {}); Assert.assertNotNull("Relations is not found!", relationsInfos); Assert.assertEquals("List of found relationsInfos is not equal to number of created relations!", @@ -299,57 +326,88 @@ public class EntityRelationControllerTest extends AbstractControllerTest { mainDevice.getUuidId(), EntityType.DEVICE ); - List relationsInfos = - JacksonUtil.convertValue(doGet(url, JsonNode.class), new TypeReference<>() { - }); + List relationsInfos = JacksonUtil.convertValue(doGet(url, JsonNode.class), new TypeReference<>() {}); Assert.assertNotNull("Relations is not found!", relationsInfos); - Assert.assertEquals("List of found relationsInfos is not equal to number of created relations!", - numOfDevices, relationsInfos.size()); + Assert.assertEquals("List of found relationsInfos is not equal to number of created relations!", numOfDevices, relationsInfos.size()); assertRelationsInfosByTo(relationsInfos); } @Test public void testDeleteRelation() throws Exception { - Device device = buildSimpleDevice("Test device 1"); + // GIVEN + Device device = createDevice("Test device 1"); EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); relation = doPost("/api/v2/relation", relation, EntityRelation.class); - String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", - mainDevice.getUuidId(), EntityType.DEVICE, - "CONTAINS", device.getUuidId(), EntityType.DEVICE - ); - - EntityRelation foundRelation = doGet(url, EntityRelation.class); - - Assert.assertNotNull("Relation is not found!", foundRelation); - Assert.assertEquals("Found relation is not equals origin!", relation, foundRelation); - Mockito.reset(tbClusterService, auditLogService); + // WHEN String deleteUrl = String.format("/api/v2/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", mainDevice.getUuidId(), EntityType.DEVICE, "CONTAINS", device.getUuidId(), EntityType.DEVICE ); var deletedRelation = doDelete(deleteUrl, EntityRelation.class); + // THEN testNotifyEntityAllOneTimeRelation(deletedRelation, - savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + tenantId, tenantAdminUser.getCustomerId(), tenantAdminUser.getId(), tenantAdminUser.getEmail(), ActionType.RELATION_DELETED, deletedRelation); - doGet(url).andExpect(status().is4xxClientError()); + getRelation(relation) + .andExpect(status().isNotFound()) + .andExpect(statusReason(is(msgErrorNotFound))); + } + + @Test + public void testDeleteRelationWithTypeEmptyOrBlank() throws Exception { + // GIVEN + Device device = createDevice("Test device"); + + // WHEN-THEN + for (String endpoint : List.of("/api/relation", "/api/v2/relation")) { + // saving relation with empty type + EntityRelation emptyRelation = createFromRelation(mainDevice, device, ""); + relationDao.saveRelation(tenantId, emptyRelation); + + // saving relation with blank type + EntityRelation blankRelation = createFromRelation(mainDevice, device, " "); + relationDao.saveRelation(tenantId, blankRelation); + + // deleting relation with empty type + String deleteEmptyUrl = String.format(endpoint + "?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + mainDevice.getUuidId(), EntityType.DEVICE, + emptyRelation.getType(), device.getUuidId(), EntityType.DEVICE + ); + doDelete(deleteEmptyUrl).andExpect(status().isOk()); + + getRelation(emptyRelation) + .andExpect(status().isNotFound()) + .andExpect(statusReason(is(msgErrorNotFound))); + + // deleting relation with blank type + String deleteBlankUrl = String.format(endpoint + "?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + mainDevice.getUuidId(), EntityType.DEVICE, + blankRelation.getType(), device.getUuidId(), EntityType.DEVICE + ); + doDelete(deleteBlankUrl).andExpect(status().isOk()); + + getRelation(blankRelation) + .andExpect(status().isNotFound()) + .andExpect(statusReason(is(msgErrorNotFound))); + } } @Test public void testDeleteRelationWithOtherFromDeviceError() throws Exception { - Device device = buildSimpleDevice("Test device 1"); + Device device = createDevice("Test device 1"); EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); doPost("/api/relation", relation).andExpect(status().isOk()); - Device device2 = buildSimpleDevice("Test device 2"); + Device device2 = createDevice("Test device 2"); String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", device2.getUuidId(), EntityType.DEVICE, "CONTAINS", device.getUuidId(), EntityType.DEVICE @@ -366,12 +424,12 @@ public class EntityRelationControllerTest extends AbstractControllerTest { @Test public void testDeleteRelationWithOtherToDeviceError() throws Exception { - Device device = buildSimpleDevice("Test device 1"); + Device device = createDevice("Test device 1"); EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); doPost("/api/relation", relation).andExpect(status().isOk()); - Device device2 = buildSimpleDevice("Test device 2"); + Device device2 = createDevice("Test device 2"); String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", mainDevice.getUuidId(), EntityType.DEVICE, "CONTAINS", device2.getUuidId(), EntityType.DEVICE @@ -411,7 +469,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest { doDelete(url).andExpect(status().isOk()); testNotifyEntityOneTimeMsgToEdgeServiceNever(null, mainDevice.getId(), mainDevice.getId(), - savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), + tenantId, tenantAdminUser.getCustomerId(), tenantAdminUser.getId(), tenantAdminUser.getEmail(), ActionType.RELATIONS_DELETED); Assert.assertTrue( @@ -442,8 +500,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest { List relations = readResponse( doPost("/api/relations", query).andExpect(status().isOk()), - new TypeReference>() { - } + new TypeReference<>() {} ); assertFoundRelations(relations, numOfDevices); @@ -467,8 +524,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest { List relations = readResponse( doPost("/api/relations", query).andExpect(status().isOk()), - new TypeReference<>() { - } + new TypeReference<>() {} ); assertFoundRelations(relations, numOfDevices); @@ -526,11 +582,11 @@ public class EntityRelationControllerTest extends AbstractControllerTest { @Test public void testCreateRelationFromTenantToDevice() throws Exception { - EntityRelation relation = new EntityRelation(tenantAdmin.getTenantId(), mainDevice.getId(), "CONTAINS"); + EntityRelation relation = new EntityRelation(tenantId, mainDevice.getId(), "CONTAINS"); relation = doPost("/api/v2/relation", relation, EntityRelation.class); String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", - tenantAdmin.getTenantId(), EntityType.TENANT, + tenantId, EntityType.TENANT, "CONTAINS", mainDevice.getUuidId(), EntityType.DEVICE ); @@ -542,12 +598,12 @@ public class EntityRelationControllerTest extends AbstractControllerTest { @Test public void testCreateRelationFromDeviceToTenant() throws Exception { - EntityRelation relation = new EntityRelation(mainDevice.getId(), tenantAdmin.getTenantId(), "CONTAINS"); + EntityRelation relation = new EntityRelation(mainDevice.getId(), tenantId, "CONTAINS"); relation = doPost("/api/v2/relation", relation, EntityRelation.class); String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", mainDevice.getUuidId(), EntityType.DEVICE, - "CONTAINS", tenantAdmin.getTenantId(), EntityType.TENANT + "CONTAINS", tenantId, EntityType.TENANT ); EntityRelation foundRelation = doGet(url, EntityRelation.class); @@ -558,7 +614,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest { @Test public void testSaveAndFindRelationDifferentTenant() throws Exception { - Device device = buildSimpleDevice("Test device 1"); + Device device = createDevice("Test device 1"); EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); doPost("/api/relation", relation).andExpect(status().isOk()); @@ -577,12 +633,20 @@ public class EntityRelationControllerTest extends AbstractControllerTest { deleteDifferentTenant(); } - private Device buildSimpleDevice(String name) throws Exception { - Device device = new Device(); + private Device createDevice(String name) { + var device = new Device(); device.setName(name); device.setType("default"); - device = doPost("/api/device", device, Device.class); - return device; + return doPost("/api/device", device, Device.class); + } + + private ResultActions getRelation(EntityRelation relation) throws Exception { + return doGet("/api/relation?" + + "fromId=" + relation.getFrom().getId() + + "&fromType=" + relation.getFrom().getEntityType() + + "&relationType=" + relation.getType() + + "&toId=" + relation.getTo().getId() + + "&toType=" + relation.getTo().getEntityType()); } private EntityRelation createFromRelation(Device mainDevice, Device device, String relationType) { @@ -591,7 +655,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest { private void createDevicesByFrom(int numOfDevices, String baseName) throws Exception { for (int i = 0; i < numOfDevices; i++) { - Device device = buildSimpleDevice(baseName + i); + Device device = createDevice(baseName + i); EntityRelation relation = createFromRelation(mainDevice, device, "CONTAINS"); doPost("/api/relation", relation).andExpect(status().isOk()); @@ -600,7 +664,7 @@ public class EntityRelationControllerTest extends AbstractControllerTest { private void createDevicesByTo(int numOfDevices, String baseName) throws Exception { for (int i = 0; i < numOfDevices; i++) { - Device device = buildSimpleDevice(baseName + i); + Device device = createDevice(baseName + i); EntityRelation relation = createFromRelation(device, mainDevice, "CONTAINS"); doPost("/api/relation", relation).andExpect(status().isOk()); } @@ -633,4 +697,5 @@ public class EntityRelationControllerTest extends AbstractControllerTest { Assert.assertEquals("Wrong relationType!", "CONTAINS", info.getType()); } } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java index 10b49dfe1d..6d095dc95f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -63,16 +63,19 @@ import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.dao.entityview.EntityViewDao; -import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.function.Function; import java.util.stream.Collectors; import static java.util.concurrent.TimeUnit.HOURS; @@ -148,6 +151,36 @@ public class EntityViewControllerTest extends AbstractControllerTest { assertEquals(savedView, foundView); } + @Test + public void testFindEntityViewByIds() throws Exception { + List assetProfiles = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + assetProfiles.add(getNewSavedEntityView("Test entity view " + i)); + } + + List expected = assetProfiles.subList(5, 15); + + String idsParam = expected.stream() + .map(ap -> ap.getId().getId().toString()) + .collect(Collectors.joining(",")); + EntityView[] foundEntityViews = doGet("/api/entityViews?entityViewIds=" + idsParam, EntityView[].class); + + Assert.assertNotNull(foundEntityViews); + Assert.assertEquals(expected.size(), foundEntityViews.length); + + Map infoById = Arrays.stream(foundEntityViews) + .collect(Collectors.toMap(info -> info.getId().getId(), Function.identity())); + + for (EntityView entityView : expected) { + UUID id = entityView.getId().getId(); + EntityView view = infoById.get(id); + Assert.assertNotNull("Entity view not found for id " + id, view); + + Assert.assertEquals(entityView.getId(), view.getId()); + Assert.assertEquals(entityView.getName(), view.getName()); + } + } + @Test public void testSaveEntityView() throws Exception { String name = "Test entity view"; @@ -853,4 +886,29 @@ public class EntityViewControllerTest extends AbstractControllerTest { EntityViewId entityViewId = getNewSavedEntityView("EntityView for Test WithRelations Transactional Exception").getId(); testEntityDaoWithRelationsTransactionalException(entityViewDao, tenantId, entityViewId, "/api/entityView/" + entityViewId); } + + @Test + public void testSaveEntityViewWithUniquifyStrategy() throws Exception { + EntityView view = new EntityView(); + view.setEntityId(testDevice.getId()); + view.setTenantId(tenantId); + view.setType("default"); + view.setName("My unique view"); + + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + + doPost("/api/entityView?nameConflictPolicy=FAIL", view).andExpect(status().isBadRequest()); + + EntityView secondView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY", view, EntityView.class); + assertThat(secondView.getName()).startsWith("My unique view_"); + + EntityView thirdView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", view, EntityView.class); + assertThat(thirdView.getName()).startsWith("My unique view-"); + + EntityView fourthView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", view, EntityView.class); + assertThat(fourthView.getName()).isEqualTo("My unique view_1"); + + EntityView fifthEntityView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", view, EntityView.class); + assertThat(fifthEntityView.getName()).isEqualTo("My unique view_2"); + } } diff --git a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java index bd0ca9def9..ee2aefc81a 100644 --- a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java index 86ba070bba..0b8767e65d 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -189,7 +189,7 @@ public class ImageControllerTest extends AbstractControllerTest { assertThat(newImageInfo.getTitle()).isEqualTo(newTitle); assertThat(newImageInfo.getDescriptor(ImageDescriptor.class)).isEqualTo(imageDescriptor); assertThat(newImageInfo.getResourceKey()).isEqualTo(imageInfo.getResourceKey()); - assertThat(newImageInfo.getPublicResourceKey()).isEqualTo(newImageInfo.getPublicResourceKey()); + assertThat(newImageInfo.getPublicResourceKey()).isEqualTo(imageInfo.getPublicResourceKey()); } @Test diff --git a/application/src/test/java/org/thingsboard/server/controller/MobileAppBundleControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/MobileAppBundleControllerTest.java index 83ccb122d3..2156d07e57 100644 --- a/application/src/test/java/org/thingsboard/server/controller/MobileAppBundleControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/MobileAppBundleControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/MobileAppControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/MobileAppControllerTest.java index d643058ef4..72b925bcf8 100644 --- a/application/src/test/java/org/thingsboard/server/controller/MobileAppControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/MobileAppControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/Oauth2ClientControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/Oauth2ClientControllerTest.java index a3c0dbb571..ca514f7690 100644 --- a/application/src/test/java/org/thingsboard/server/controller/Oauth2ClientControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/Oauth2ClientControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/OtaPackageControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/OtaPackageControllerTest.java index 3fc839996c..5425c34740 100644 --- a/application/src/test/java/org/thingsboard/server/controller/OtaPackageControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/OtaPackageControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,7 +37,7 @@ import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.service.DaoSqlTest; import java.nio.ByteBuffer; diff --git a/application/src/test/java/org/thingsboard/server/controller/QrCodeSettingsControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/QrCodeSettingsControllerTest.java index 1a17f79ac3..797b1a0d5c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/QrCodeSettingsControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/QrCodeSettingsControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/RepositorySettingsTest.java b/application/src/test/java/org/thingsboard/server/controller/RepositorySettingsTest.java index 9cd75e9a3c..072e83cc47 100644 --- a/application/src/test/java/org/thingsboard/server/controller/RepositorySettingsTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/RepositorySettingsTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/RpcControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/RpcControllerTest.java index 38d73b2966..7a32a4785c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/RpcControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/RpcControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java index a6a022347f..d53e69d721 100644 --- a/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -47,12 +47,17 @@ import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.rule.RuleChainDao; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; @@ -231,6 +236,44 @@ public class RuleChainControllerTest extends AbstractControllerTest { Assert.assertEquals(savedRuleChain, foundRuleChain); } + @Test + public void testFindRuleChainsByIds() throws Exception { + List ruleChains = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + RuleChain ruleChain = new RuleChain(); + ruleChain.setName("RuleChain " + i); + ruleChains.add(doPost("/api/ruleChain", ruleChain, RuleChain.class)); + } + + List expected = ruleChains.subList(5, 15); + + String idsParam = expected.stream() + .map(rc -> rc.getId().getId().toString()) + .collect(Collectors.joining(",")); + + RuleChain[] result = doGet( + "/api/ruleChains?ruleChainIds=" + idsParam, + RuleChain[].class + ); + + Assert.assertNotNull(result); + Assert.assertEquals(expected.size(), result.length); + + Map rcById = Arrays.stream(result) + .collect(Collectors.toMap(rc -> rc.getId().getId(), Function.identity())); + + for (RuleChain rc : expected) { + UUID id = rc.getId().getId(); + RuleChain found = rcById.get(id); + Assert.assertNotNull("RuleChain not found for id " + id, found); + + Assert.assertEquals(rc.getId(), found.getId()); + Assert.assertEquals(rc.getName(), found.getName()); + Assert.assertEquals(rc.getTenantId(), found.getTenantId()); + } + } + + @Test public void testDeleteRuleChain() throws Exception { RuleChain ruleChain = new RuleChain(); diff --git a/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerTest.java index ac79b180bb..81c74de038 100644 --- a/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/RuleEngineControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java index d88aaa2756..7fb316dd7e 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,11 +25,12 @@ import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockPart; import org.springframework.test.web.servlet.ResultActions; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Dashboard; -import org.thingsboard.server.common.data.DashboardInfo; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; @@ -47,15 +48,14 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; -import org.thingsboard.server.common.data.widget.WidgetTypeInfo; -import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.ArrayList; import java.util.Base64; -import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Objects; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; @@ -64,7 +64,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @DaoSqlTest public class TbResourceControllerTest extends AbstractControllerTest { - private IdComparator idComparator = new IdComparator<>(); + private final IdComparator idComparator = new IdComparator<>(); private static final String DEFAULT_FILE_NAME = "test.jks"; private static final String DEFAULT_FILE_NAME_2 = "test2.jks"; @@ -126,13 +126,10 @@ public class TbResourceControllerTest extends AbstractControllerTest { Assert.assertEquals(DEFAULT_FILE_NAME, savedResource.getResourceKey()); Assert.assertArrayEquals(resource.getData(), download(savedResource.getId())); - TbResource foundResource = doGet("/api/resource/" + savedResource.getId().getId().toString(), TbResource.class); - foundResource.setTitle("My new resource"); - foundResource.setData(null); - - savedResource = save(foundResource); - - Assert.assertEquals(foundResource.getTitle(), savedResource.getTitle()); + String resourceTitle = "My new resource"; + savedResource.setTitle(resourceTitle); + savedResource = doPut("/api/resource/" + savedResource.getUuidId() + "/info", savedResource, TbResourceInfo.class); + assertThat(savedResource.getTitle()).isEqualTo(resourceTitle); testNotifyEntityAllOneTimeLogEntityActionEntityEqClass(savedResource, savedResource.getId(), savedResource.getId(), savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), @@ -501,8 +498,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, cntEntity, cntEntity, cntEntity); - Collections.sort(resources, idComparator); - Collections.sort(loadedResources, idComparator); + resources.sort(idComparator); + loadedResources.sort(idComparator); Assert.assertEquals(resources, loadedResources); } @@ -549,8 +546,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, jksCntEntity + lwm2mCntEntity, jksCntEntity + lwm2mCntEntity, jksCntEntity + lwm2mCntEntity); - Collections.sort(resources, idComparator); - Collections.sort(loadedResources, idComparator); + resources.sort(idComparator); + loadedResources.sort(idComparator); Assert.assertEquals(resources, loadedResources); } @@ -581,8 +578,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { } } while (pageData.hasNext()); - Collections.sort(resources, idComparator); - Collections.sort(loadedResources, idComparator); + resources.sort(idComparator); + loadedResources.sort(idComparator); Assert.assertEquals(resources, loadedResources); @@ -654,8 +651,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { } } while (pageData.hasNext()); - Collections.sort(jksResources, idComparator); - Collections.sort(loadedResources, idComparator); + jksResources.sort(idComparator); + loadedResources.sort(idComparator); Assert.assertEquals(jksResources, loadedResources); @@ -736,8 +733,8 @@ public class TbResourceControllerTest extends AbstractControllerTest { } } while (pageData.hasNext()); - Collections.sort(expectedResources, idComparator); - Collections.sort(loadedResources, idComparator); + expectedResources.sort(idComparator); + loadedResources.sort(idComparator); Assert.assertEquals(expectedResources, loadedResources); @@ -770,7 +767,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { MockHttpServletResponse response = resultActions.andReturn().getResponse(); String eTag = response.getHeader("ETag"); Assert.assertNotNull(eTag); - Assert.assertEquals(Base64.getEncoder().encodeToString(response.getContentAsByteArray()), TEST_DATA); + Assert.assertEquals(TEST_DATA, Base64.getEncoder().encodeToString(response.getContentAsByteArray())); //download with if-none-match header HttpHeaders headers = new HttpHeaders(); @@ -814,7 +811,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { MockHttpServletResponse response = resultActions.andReturn().getResponse(); String eTag = response.getHeader("ETag"); Assert.assertNotNull(eTag); - Assert.assertEquals(Base64.getEncoder().encodeToString(response.getContentAsByteArray()), TEST_DATA); + Assert.assertEquals(TEST_DATA, Base64.getEncoder().encodeToString(response.getContentAsByteArray())); //download with if-none-match header HttpHeaders headers = new HttpHeaders(); @@ -859,10 +856,10 @@ public class TbResourceControllerTest extends AbstractControllerTest { .andExpect(status().isBadRequest()) .andExpect(statusReason(containsString("can't be updated"))); - foundResource.setData(null); - foundResource.setTitle("Updated resource"); - savedResource = doPost("/api/resource", foundResource, TbResource.class); - assertThat(savedResource.getTitle()).isEqualTo("Updated resource"); + String resourceTitle = "Updated resource"; + savedResource.setTitle(resourceTitle); + savedResource = doPut("/api/resource/" + savedResource.getUuidId() + "/info", savedResource, TbResourceInfo.class); + assertThat(savedResource.getTitle()).isEqualTo(resourceTitle); assertThat(savedResource.getFileName()).isEqualTo(resource.getFileName()); assertThat(savedResource.getEtag()).isEqualTo(resource.getEtag()); assertThat(download(savedResource.getId())).asBase64Encoded().isEqualTo(TEST_DATA); @@ -923,8 +920,20 @@ public class TbResourceControllerTest extends AbstractControllerTest { } private TbResourceInfo save(TbResource tbResource) throws Exception { - return doPostWithTypedResponse("/api/resource", tbResource, new TypeReference<>() { - }); + byte[] data = tbResource.getData() != null ? tbResource.getData() : tbResource.getEncodedData() != null ? Base64.getDecoder().decode(tbResource.getEncodedData()) : null; + List parts = new ArrayList<>(); + parts.add(new MockPart("resourceType", tbResource.getResourceType().name().getBytes())); + if (tbResource.getTitle() != null) { + parts.add(new MockPart("title", tbResource.getTitle().getBytes())); + } + if (tbResource.getDescriptor() != null) { + parts.add(new MockPart("descriptor", tbResource.getDescriptor().toString().getBytes())); + } + if (tbResource.getResourceSubType() != null) { + parts.add(new MockPart("resourceSubType", tbResource.getResourceSubType().name().getBytes())); + } + + return uploadResource(HttpMethod.POST, "/api/resource/upload", tbResource.getFileName(), tbResource.getResourceType().getMediaType(), data, parts); } private TbResourceInfo findResourceInfo(TbResourceId id) throws Exception { @@ -949,7 +958,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { for (String model : models) { String fileName = model + ".xml"; - byte[] bytes = IOUtils.toByteArray(getClass().getClassLoader().getResourceAsStream("lwm2m/" + fileName)); + byte[] bytes = IOUtils.toByteArray(Objects.requireNonNull(getClass().getClassLoader().getResourceAsStream("lwm2m/" + fileName))); TbResource resource = new TbResource(); resource.setResourceType(ResourceType.LWM2M_MODEL); diff --git a/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java b/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java index 1c5b1dd154..26705ddacb 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java +++ b/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java index ad3c6d5312..dd51cdd440 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,7 +19,10 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Assert; import org.junit.Test; import org.springframework.test.context.TestPropertySource; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; @@ -110,6 +113,13 @@ public class TelemetryControllerTest extends AbstractControllerTest { var monthResult = result.get("t").get(0); Assert.assertEquals(22L, monthResult.get("value").asLong()); Assert.assertEquals(middleOfTheInterval, monthResult.get("ts").asLong()); + + // get all latest (without keys parameter) + ObjectNode allLatest = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + + "/values/timeseries?startTs={startTs}&endTs={endTs}&agg={agg}&intervalType={intervalType}&timeZone={timeZone}", + ObjectNode.class, startTs, endTs, "SUM", "WEEK_ISO", "Europe/Kyiv"); + Assert.assertNotNull(allLatest); + Assert.assertNotNull(allLatest.get("t")); } @Test @@ -232,6 +242,63 @@ public class TelemetryControllerTest extends AbstractControllerTest { doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", invalidRequestBody2, String.class, status().isBadRequest()); } + @Test + public void testDeleteTelemetryByKeyWithComma() throws Exception { + loginTenantAdmin(); + Device device = createDevice(); + + String tsKey = "key1,key2"; + String testBody = JacksonUtil.newObjectNode() + .put(tsKey, "value") + .toString(); + doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", testBody, String.class, status().isOk()); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("key", tsKey); + params.add("deleteAllDataForKeys", "true"); + + ObjectNode tsData = readResponse(doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries", params), ObjectNode.class); + assertThat(tsData.get("key1,key2").get(0).get("value").asText()).isEqualTo("value"); + + doDeleteAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/delete", params); + + ObjectNode tsDataAfterDeletion = readResponse(doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries", params), ObjectNode.class); + Assert.assertTrue(tsDataAfterDeletion.get("key1,key2").get(0).get("value").isNull()); + } + + @Test + public void testDeleteTelemetryByKeysWithComma() throws Exception { + loginTenantAdmin(); + Device device = createDevice(); + + String keyWithComma = "key1,key2"; + String testBody = JacksonUtil.newObjectNode() + .put(keyWithComma, "value") + .toString(); + doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", testBody, String.class, status().isOk()); + + String key = "key3"; + String testBody2 = JacksonUtil.newObjectNode() + .put(key, "value") + .toString(); + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("key", keyWithComma); + params.add("key", key); + params.add("deleteAllDataForKeys", "true"); + + doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", testBody2, String.class, status().isOk()); + + ObjectNode tsData = readResponse(doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries", params), ObjectNode.class); + assertThat(tsData.get("key1,key2").get(0).get("value").asText()).isEqualTo("value"); + assertThat(tsData.get("key3").get(0).get("value").asText()).isEqualTo("value"); + + doDeleteAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/delete", params); + + ObjectNode tsDataAfterDeletion = readResponse(doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries", params), ObjectNode.class); + Assert.assertTrue(tsDataAfterDeletion.get("key1,key2").get(0).get("value").isNull()); + Assert.assertTrue(tsDataAfterDeletion.get("key3").get(0).get("value").isNull()); + } + private Device createDevice() throws Exception { String testToken = "TEST_TOKEN"; diff --git a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java index ba45be8e28..fb86ea7b43 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,8 +27,8 @@ import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentMatcher; import org.mockito.Mockito; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.servlet.ResultActions; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.actors.ActorSystemContext; @@ -68,6 +68,7 @@ import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.discovery.QueueKey; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -77,6 +78,7 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -109,11 +111,11 @@ public class TenantControllerTest extends AbstractControllerTest { ListeningExecutorService executor; - @SpyBean + @MockitoSpyBean private PartitionService partitionService; - @SpyBean + @MockitoSpyBean private ActorSystemContext actorContext; - @SpyBean + @MockitoSpyBean private TbQueueAdmin queueAdmin; @Before @@ -187,6 +189,51 @@ public class TenantControllerTest extends AbstractControllerTest { deleteTenant(savedTenant.getId()); } + @Test + public void testFindTenantsByIds() throws Exception { + loginSysAdmin(); + + List savedTenants = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant " + i); + savedTenants.add(saveTenant(tenant)); + } + + String idsParam = savedTenants.stream() + .map(t -> t.getId().getId().toString()) + .collect(Collectors.joining(",")); + + Tenant[] foundTenants = doGet("/api/tenants?tenantIds=" + idsParam, Tenant[].class); + + Assert.assertNotNull(foundTenants); + Assert.assertEquals(savedTenants.size(), foundTenants.length); + + Map foundById = Arrays.stream(foundTenants) + .collect(Collectors.toMap(t -> t.getId().getId(), Function.identity())); + + for (Tenant savedTenant : savedTenants) { + UUID id = savedTenant.getId().getId(); + Tenant foundTenant = foundById.get(id); + Assert.assertNotNull("Tenant not found for id " + id, foundTenant); + Assert.assertEquals(savedTenant, foundTenant); + } + + loginTenantAdmin(); + Tenant[] foundTenantsByTenant = doGet("/api/tenants?tenantIds=" + idsParam, Tenant[].class); + assertThat(foundTenantsByTenant).isEmpty(); + + Tenant[] foundCurrentTenant = doGet("/api/tenants?tenantIds=" + tenantId, Tenant[].class); + assertThat(foundCurrentTenant).hasSize(1); + assertThat(foundCurrentTenant[0].getTenantId()).isEqualTo(tenantId); + + loginSysAdmin(); + for (Tenant savedTenant : savedTenants) { + deleteTenant(savedTenant.getId()); + } + } + + @Test public void testFindTenantInfoById() throws Exception { loginSysAdmin(); @@ -243,15 +290,37 @@ public class TenantControllerTest extends AbstractControllerTest { .andExpect(statusReason(containsString(msgErrorNoFound("Tenant", tenantIdStr)))); } + @Test + public void testDeleteTenantByTenantAdmin() throws Exception { + loginSysAdmin(); + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + Tenant savedTenant = saveTenant(tenant); + + //login as tenant admin + User tenantAdminUser = new User(); + tenantAdminUser.setAuthority(Authority.TENANT_ADMIN); + tenantAdminUser.setTenantId(savedTenant.getId()); + tenantAdminUser.setEmail("tenantToDelete@thingsboard.io"); + + createUserAndLogin(tenantAdminUser, TENANT_ADMIN_PASSWORD); + + String tenantIdStr = savedTenant.getId().getId().toString(); + deleteTenant(savedTenant.getId()); + loginSysAdmin(); + doGet("/api/tenant/" + tenantIdStr) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Tenant", tenantIdStr)))); + } + @Test public void testFindTenants() throws Exception { loginSysAdmin(); - List tenants = new ArrayList<>(); PageLink pageLink = new PageLink(17); PageData pageData = doGetTypedWithPageLink("/api/tenants?", PAGE_DATA_TENANT_TYPE_REF, pageLink); Assert.assertFalse(pageData.hasNext()); Assert.assertEquals(1, pageData.getData().size()); - tenants.addAll(pageData.getData()); + List tenants = new ArrayList<>(pageData.getData()); Mockito.reset(tbClusterService); @@ -377,12 +446,11 @@ public class TenantControllerTest extends AbstractControllerTest { @Test public void testFindTenantInfos() throws Exception { loginSysAdmin(); - List tenants = new ArrayList<>(); PageLink pageLink = new PageLink(17); PageData pageData = doGetTypedWithPageLink("/api/tenantInfos?", PAGE_DATA_TENANT_INFO_TYPE_REF, pageLink); Assert.assertFalse(pageData.hasNext()); Assert.assertEquals(1, pageData.getData().size()); - tenants.addAll(pageData.getData()); + List tenants = new ArrayList<>(pageData.getData()); List> createFutures = new ArrayList<>(56); for (int i = 0; i < 56; i++) { diff --git a/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java index 8f83f2c186..42a27eb6d8 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -42,8 +42,12 @@ import org.thingsboard.server.queue.TbQueueCallback; import java.lang.reflect.Field; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; import java.util.stream.Collectors; import static org.hamcrest.Matchers.containsString; @@ -120,6 +124,43 @@ public class TenantProfileControllerTest extends AbstractControllerTest { Assert.assertEquals(savedTenantProfile.getName(), foundTenantProfileInfo.getName()); } + @Test + public void testFindTenantProfilesByIds() throws Exception { + loginSysAdmin(); + List tenantProfiles = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + TenantProfile tenantProfile = this.createTenantProfile("Tenant Profile " + i); + tenantProfiles.add(doPost("/api/tenantProfile", tenantProfile, TenantProfile.class)); + } + + List expected = tenantProfiles.subList(2, 7); + + String idsParam = expected.stream() + .map(tp -> tp.getId().getId().toString()) + .collect(Collectors.joining(",")); + + TenantProfile[] result = doGet( + "/api/tenantProfiles?ids=" + idsParam, + TenantProfile[].class + ); + + Assert.assertNotNull(result); + Assert.assertEquals(expected.size(), result.length); + + Map tpById = Arrays.stream(result) + .collect(Collectors.toMap(tp -> tp.getId().getId(), Function.identity())); + + for (TenantProfile tp : expected) { + UUID id = tp.getId().getId(); + TenantProfile found = tpById.get(id); + Assert.assertNotNull("TenantProfile not found for id " + id, found); + + Assert.assertEquals(tp.getId(), found.getId()); + Assert.assertEquals(tp.getName(), found.getName()); + } + } + + @Test public void testFindDefaultTenantProfileInfo() throws Exception { loginSysAdmin(); diff --git a/application/src/test/java/org/thingsboard/server/controller/TrendzControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TrendzControllerTest.java index 3612c4a584..04b58a084c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TrendzControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TrendzControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java index 3611409cb7..c8b5bf06c9 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,6 +30,8 @@ import org.springframework.web.util.UriComponentsBuilder; import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.server.common.data.CacheConstants; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.notification.targets.platform.AllUsersFilter; +import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter; import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings; import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFaAccountConfig; @@ -48,6 +50,7 @@ import org.thingsboard.server.service.security.auth.mfa.provider.impl.TotpTwoFaP import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -85,7 +88,6 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { twoFaConfigManager.deletePlatformTwoFaSettings(tenantId); } - @Test public void testSavePlatformTwoFaSettings() throws Exception { loginSysAdmin(); @@ -102,15 +104,32 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { twoFaSettings.setVerificationCodeCheckRateLimit("3:900"); twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); twoFaSettings.setTotalAllowedTimeForVerification(3600); + twoFaSettings.setEnforceTwoFa(true); + twoFaSettings.setEnforcedUsersFilter(new AllUsersFilter()); - doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk()); + saveTwoFaSettings(twoFaSettings); - PlatformTwoFaSettings savedTwoFaSettings = readResponse(doGet("/api/2fa/settings").andExpect(status().isOk()), PlatformTwoFaSettings.class); + PlatformTwoFaSettings savedTwoFaSettings = findTwoFaSettings(); assertThat(savedTwoFaSettings.getProviders()).hasSize(2); assertThat(savedTwoFaSettings.getProviders()).contains(totpTwoFaProviderConfig, smsTwoFaProviderConfig); } + @Test + public void testSavePlatformTwoFaSettingsWithEnforceTwoFaWithoutProviders() throws Exception { + loginSysAdmin(); + + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); + twoFaSettings.setProviders(List.of()); + twoFaSettings.setMinVerificationCodeSendPeriod(5); + twoFaSettings.setVerificationCodeCheckRateLimit("3:900"); + twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); + twoFaSettings.setTotalAllowedTimeForVerification(3600); + twoFaSettings.setEnforceTwoFa(true); + + doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isBadRequest()); + } + @Test public void testSavePlatformTwoFaSettings_validationError() throws Exception { loginSysAdmin(); @@ -157,17 +176,6 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { assertThat(errorResponse).containsIgnoringCase("verificationCodeLifetime is required"); } - private String savePlatformTwoFaSettingsAndGetError(TwoFaProviderConfig invalidTwoFaProviderConfig) throws Exception { - PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); - twoFaSettings.setProviders(Collections.singletonList(invalidTwoFaProviderConfig)); - twoFaSettings.setMinVerificationCodeSendPeriod(5); - twoFaSettings.setTotalAllowedTimeForVerification(100); - - return getErrorMessage(doPost("/api/2fa/settings", twoFaSettings) - .andExpect(status().isBadRequest())); - } - - @Test public void testSaveTwoFaAccountConfig_providerNotConfigured() throws Exception { configureSmsTwoFaProvider("${code}"); @@ -268,24 +276,6 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { assertThat(errorMessage).containsIgnoringCase("verification code is incorrect"); } - private TotpTwoFaAccountConfig generateTotpTwoFaAccountConfig(TotpTwoFaProviderConfig totpTwoFaProviderConfig) throws Exception { - TwoFaAccountConfig generatedTwoFaAccountConfig = readResponse(doPost("/api/2fa/account/config/generate?providerType=TOTP") - .andExpect(status().isOk()), TwoFaAccountConfig.class); - assertThat(generatedTwoFaAccountConfig).isInstanceOf(TotpTwoFaAccountConfig.class); - - assertThat(((TotpTwoFaAccountConfig) generatedTwoFaAccountConfig)).satisfies(accountConfig -> { - UriComponents otpAuthUrl = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build(); - assertThat(otpAuthUrl.getScheme()).isEqualTo("otpauth"); - assertThat(otpAuthUrl.getHost()).isEqualTo("totp"); - assertThat(otpAuthUrl.getQueryParams().getFirst("issuer")).isEqualTo(totpTwoFaProviderConfig.getIssuerName()); - assertThat(otpAuthUrl.getPath()).isEqualTo("/%s:%s", totpTwoFaProviderConfig.getIssuerName(), TENANT_ADMIN_EMAIL); - assertThat(otpAuthUrl.getQueryParams().getFirst("secret")).satisfies(secretKey -> { - assertDoesNotThrow(() -> Base32.decode(secretKey)); - }); - }); - return (TotpTwoFaAccountConfig) generatedTwoFaAccountConfig; - } - @Test public void testGetTwoFaAccountConfig_whenProviderNotConfigured() throws Exception { testVerifyAndSaveTotpTwoFaAccountConfig(); @@ -419,6 +409,56 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { assertThat(accountConfig).isEqualTo(initialSmsTwoFaAccountConfig); } + @Test + public void testIsTwoFaEnabled() throws Exception { + configureSmsTwoFaProvider("${code}"); + SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig(); + accountConfig.setPhoneNumber("+38050505050"); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUser, accountConfig); + + assertThat(twoFactorAuthService.isTwoFaEnabled(tenantId, tenantAdminUser)).isTrue(); + } + + @Test + public void testDeleteTwoFaAccountConfig() throws Exception { + configureSmsTwoFaProvider("${code}"); + loginTenantAdmin(); + SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig(); + accountConfig.setPhoneNumber("+38050505050"); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUser, accountConfig); + + AccountTwoFaSettings accountTwoFaSettings = readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class); + TwoFaAccountConfig savedAccountConfig = accountTwoFaSettings.getConfigs().get(TwoFaProviderType.SMS); + assertThat(savedAccountConfig).isEqualTo(accountConfig); + + PlatformTwoFaSettings twoFaSettings = twoFaConfigManager.getPlatformTwoFaSettings(TenantId.SYS_TENANT_ID, true).get(); + twoFaSettings.setEnforceTwoFa(true); + TenantAdministratorsFilter enforcedUsersFilter = new TenantAdministratorsFilter(); + enforcedUsersFilter.setTenantsIds(Set.of(tenantId.getId())); + twoFaSettings.setEnforcedUsersFilter(enforcedUsersFilter); + twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); + + String errorMessage = getErrorMessage(doDelete("/api/2fa/account/config?providerType=SMS") + .andExpect(status().isBadRequest())); + assertThat(errorMessage).isEqualTo("At least one 2FA provider is required"); + + twoFaSettings.setEnforceTwoFa(false); + twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); + + doDelete("/api/2fa/account/config?providerType=SMS").andExpect(status().isOk()); + + assertThat(readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class).getConfigs()) + .doesNotContainKey(TwoFaProviderType.SMS); + } + + private PlatformTwoFaSettings findTwoFaSettings() throws Exception { + return doGet("/api/2fa/settings", PlatformTwoFaSettings.class); + } + + private void saveTwoFaSettings(PlatformTwoFaSettings twoFaSettings) throws Exception { + doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk()); + } + private TotpTwoFaProviderConfig configureTotpTwoFaProvider() throws Exception { TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); totpTwoFaProviderConfig.setIssuerName("tb"); @@ -441,37 +481,35 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { twoFaSettings.setProviders(Arrays.stream(providerConfigs).collect(Collectors.toList())); twoFaSettings.setMinVerificationCodeSendPeriod(5); twoFaSettings.setTotalAllowedTimeForVerification(100); - doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk()); + saveTwoFaSettings(twoFaSettings); } - @Test - public void testIsTwoFaEnabled() throws Exception { - configureSmsTwoFaProvider("${code}"); - SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig(); - accountConfig.setPhoneNumber("+38050505050"); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig); + private TotpTwoFaAccountConfig generateTotpTwoFaAccountConfig(TotpTwoFaProviderConfig totpTwoFaProviderConfig) throws Exception { + TwoFaAccountConfig generatedTwoFaAccountConfig = readResponse(doPost("/api/2fa/account/config/generate?providerType=TOTP") + .andExpect(status().isOk()), TwoFaAccountConfig.class); + assertThat(generatedTwoFaAccountConfig).isInstanceOf(TotpTwoFaAccountConfig.class); - assertThat(twoFactorAuthService.isTwoFaEnabled(tenantId, tenantAdminUserId)).isTrue(); + assertThat(((TotpTwoFaAccountConfig) generatedTwoFaAccountConfig)).satisfies(accountConfig -> { + UriComponents otpAuthUrl = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build(); + assertThat(otpAuthUrl.getScheme()).isEqualTo("otpauth"); + assertThat(otpAuthUrl.getHost()).isEqualTo("totp"); + assertThat(otpAuthUrl.getQueryParams().getFirst("issuer")).isEqualTo(totpTwoFaProviderConfig.getIssuerName()); + assertThat(otpAuthUrl.getPath()).isEqualTo("/%s:%s", totpTwoFaProviderConfig.getIssuerName(), TENANT_ADMIN_EMAIL); + assertThat(otpAuthUrl.getQueryParams().getFirst("secret")).satisfies(secretKey -> { + assertDoesNotThrow(() -> Base32.decode(secretKey)); + }); + }); + return (TotpTwoFaAccountConfig) generatedTwoFaAccountConfig; } - @Test - public void testDeleteTwoFaAccountConfig() throws Exception { - configureSmsTwoFaProvider("${code}"); - SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig(); - accountConfig.setPhoneNumber("+38050505050"); - - loginTenantAdmin(); - - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig); - - AccountTwoFaSettings accountTwoFaSettings = readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class); - TwoFaAccountConfig savedAccountConfig = accountTwoFaSettings.getConfigs().get(TwoFaProviderType.SMS); - assertThat(savedAccountConfig).isEqualTo(accountConfig); - - doDelete("/api/2fa/account/config?providerType=SMS").andExpect(status().isOk()); + private String savePlatformTwoFaSettingsAndGetError(TwoFaProviderConfig invalidTwoFaProviderConfig) throws Exception { + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); + twoFaSettings.setProviders(Collections.singletonList(invalidTwoFaProviderConfig)); + twoFaSettings.setMinVerificationCodeSendPeriod(5); + twoFaSettings.setTotalAllowedTimeForVerification(100); - assertThat(readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class).getConfigs()) - .doesNotContainKey(TwoFaProviderType.SMS); + return getErrorMessage(doPost("/api/2fa/settings", twoFaSettings) + .andExpect(status().isBadRequest())); } } diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java index 10d06150dc..ca81b97a48 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,6 +25,7 @@ import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.web.util.UriComponentsBuilder; import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; @@ -33,6 +34,8 @@ import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.audit.AuditLog; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.notification.targets.platform.AllUsersFilter; +import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.common.data.page.TimePageLink; @@ -58,6 +61,7 @@ import java.time.Duration; import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -116,7 +120,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { public void testTwoFa_totp() throws Exception { TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); doPost("/api/auth/2fa/verification/send?providerType=TOTP") .andExpect(status().isOk()); @@ -136,7 +140,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { public void testTwoFa_sms() throws Exception { configureSmsTwoFa(); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); doPost("/api/auth/2fa/verification/send?providerType=SMS") .andExpect(status().isOk()); @@ -160,7 +164,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { twoFaSettings.setTotalAllowedTimeForVerification(65); }); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); await("expiration of the pre-verification token") .atLeast(Duration.ofSeconds(30).plusMillis(500)) @@ -177,7 +181,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); }); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); Stream.generate(() -> StringUtils.randomNumeric(6)) .limit(9) @@ -206,7 +210,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { twoFaSettings.setMinVerificationCodeSendPeriod(10); }); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); doPost("/api/auth/2fa/verification/send?providerType=TOTP") .andExpect(status().isOk()); @@ -230,7 +234,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { twoFaSettings.setVerificationCodeCheckRateLimit("3:10"); }); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); for (int i = 0; i < 3; i++) { String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect") @@ -258,7 +262,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { @Test public void testCheckVerificationCode_invalidVerificationCode() throws Exception { configureTotpTwoFa(); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); for (String invalidVerificationCode : new String[]{"1234567", "ab1212", "12311 ", "oewkriwejqf"}) { String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=" + invalidVerificationCode) @@ -273,7 +277,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { smsTwoFaProviderConfig.setVerificationCodeLifetime(10); }); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); doPost("/api/auth/2fa/verification/send?providerType=SMS").andExpect(status().isOk()); @@ -296,7 +300,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { public void testTwoFa_logLoginAction() throws Exception { TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); await("async audit log saving").during(1, TimeUnit.SECONDS); doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect") @@ -334,7 +338,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { @Test public void testAuthWithoutTwoFaAccountConfig() throws ThingsboardException { configureTotpTwoFa(); - twoFaConfigManager.deleteTwoFaAccountConfig(tenantId, user.getId(), TwoFaProviderType.TOTP); + twoFaConfigManager.deleteTwoFaAccountConfig(tenantId, user, TwoFaProviderType.TOTP); assertDoesNotThrow(() -> { login(username, password); @@ -368,17 +372,17 @@ public class TwoFactorAuthTest extends AbstractControllerTest { TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(twoFaUser, TwoFaProviderType.TOTP); totpTwoFaAccountConfig.setUseByDefault(true); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), totpTwoFaAccountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, totpTwoFaAccountConfig); SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); smsTwoFaAccountConfig.setPhoneNumber("+38012312322"); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), smsTwoFaAccountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, smsTwoFaAccountConfig); EmailTwoFaAccountConfig emailTwoFaAccountConfig = new EmailTwoFaAccountConfig(); emailTwoFaAccountConfig.setEmail(twoFaUser.getEmail()); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), emailTwoFaAccountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, emailTwoFaAccountConfig); - logInWithPreVerificationToken(twoFaUser.getEmail(), "12345678"); + logInWithMfaToken(twoFaUser.getEmail(), "12345678", Authority.PRE_VERIFICATION_TOKEN); Map providersInfos = readResponse(doGet("/api/auth/2fa/providers").andExpect(status().isOk()), new TypeReference>() {}).stream() .collect(Collectors.toMap(TwoFactorAuthController.TwoFaProviderInfo::getType, v -> v)); @@ -395,13 +399,79 @@ public class TwoFactorAuthTest extends AbstractControllerTest { assertThat(providersInfos.get(TwoFaProviderType.EMAIL).isDefault()).isFalse(); } - private void logInWithPreVerificationToken(String username, String password) throws Exception { + @Test + public void testEnforceTwoFa() throws Exception { + TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); + totpTwoFaProviderConfig.setIssuerName("tb"); + + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); + twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{totpTwoFaProviderConfig}).collect(Collectors.toList())); + twoFaSettings.setMinVerificationCodeSendPeriod(5); + twoFaSettings.setTotalAllowedTimeForVerification(100); + twoFaSettings.setEnforceTwoFa(true); + TenantAdministratorsFilter enforcedUsersFilter = new TenantAdministratorsFilter(); + enforcedUsersFilter.setTenantsIds(Set.of(tenantId.getId())); + twoFaSettings.setEnforcedUsersFilter(enforcedUsersFilter); + twoFaSettings = twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); + + logInWithMfaToken(username, password, Authority.MFA_CONFIGURATION_TOKEN); + + TotpTwoFaAccountConfig totpTwoFaAccountConfig = doPost("/api/2fa/account/config/generate?providerType=" + totpTwoFaProviderConfig.getProviderType(), TotpTwoFaAccountConfig.class); + + String secret = UriComponentsBuilder.fromUriString(totpTwoFaAccountConfig.getAuthUrl()).build() + .getQueryParams().getFirst("secret"); + String verificationCode = new Totp(secret).now(); + readResponse(doPost("/api/2fa/account/config?verificationCode=" + verificationCode, totpTwoFaAccountConfig).andExpect(status().isOk()), JsonNode.class); + + JwtPair tokenPair = readResponse(doPost("/api/auth/2fa/login").andExpect(status().isOk()), JwtPair.class); + assertThat(tokenPair.getToken()).isNotEmpty(); + assertThat(tokenPair.getRefreshToken()).isNotEmpty(); + validateAndSetJwtToken(tokenPair, username); + + doGet("/api/user/" + user.getId()).andExpect(status().isOk()); + + // verifying enforced users filter + createDifferentTenant(); + doGet("/api/user/" + savedDifferentTenantUser.getId()).andExpect(status().isOk()); + } + + @Test + public void testEnforceTwoFa_sysadmin() throws Exception { + TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); + totpTwoFaProviderConfig.setIssuerName("tb"); + + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); + twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{totpTwoFaProviderConfig}).collect(Collectors.toList())); + twoFaSettings.setMinVerificationCodeSendPeriod(5); + twoFaSettings.setTotalAllowedTimeForVerification(100); + twoFaSettings.setEnforceTwoFa(true); + AllUsersFilter enforcedUsersFilter = new AllUsersFilter(); + twoFaSettings.setEnforcedUsersFilter(enforcedUsersFilter); + twoFaSettings = twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); + + logInWithMfaToken(SYS_ADMIN_EMAIL, SYS_ADMIN_PASSWORD, Authority.MFA_CONFIGURATION_TOKEN); + + TotpTwoFaAccountConfig totpTwoFaAccountConfig = doPost("/api/2fa/account/config/generate?providerType=" + totpTwoFaProviderConfig.getProviderType(), TotpTwoFaAccountConfig.class); + String secret = UriComponentsBuilder.fromUriString(totpTwoFaAccountConfig.getAuthUrl()).build() + .getQueryParams().getFirst("secret"); + String verificationCode = new Totp(secret).now(); + readResponse(doPost("/api/2fa/account/config?verificationCode=" + verificationCode, totpTwoFaAccountConfig).andExpect(status().isOk()), JsonNode.class); + + JwtPair tokenPair = readResponse(doPost("/api/auth/2fa/login").andExpect(status().isOk()), JwtPair.class); + assertThat(tokenPair.getToken()).isNotEmpty(); + assertThat(tokenPair.getRefreshToken()).isNotEmpty(); + validateAndSetJwtToken(tokenPair, SYS_ADMIN_EMAIL); + + doGet("/api/user/" + user.getId()).andExpect(status().isOk()); + } + + private void logInWithMfaToken(String username, String password, Authority expectedScope) throws Exception { LoginRequest loginRequest = new LoginRequest(username, password); JwtPair response = readResponse(doPost("/api/auth/login", loginRequest).andExpect(status().isOk()), JwtPair.class); assertThat(response.getToken()).isNotNull(); assertThat(response.getRefreshToken()).isNull(); - assertThat(response.getScope()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN); + assertThat(response.getScope()).isEqualTo(expectedScope); this.token = response.getToken(); } @@ -418,7 +488,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(user, TwoFaProviderType.TOTP); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), totpTwoFaAccountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user, totpTwoFaAccountConfig); return totpTwoFaAccountConfig; } @@ -436,7 +506,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); smsTwoFaAccountConfig.setPhoneNumber("+38050505050"); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), smsTwoFaAccountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user, smsTwoFaAccountConfig); return smsTwoFaAccountConfig; } diff --git a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java index 28d43699fd..957b3721e0 100644 --- a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -50,13 +50,17 @@ import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.settings.StarredDashboardInfo; import org.thingsboard.server.common.data.settings.UserDashboardsInfo; import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.user.UserDao; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -263,6 +267,37 @@ public class UserControllerTest extends AbstractControllerTest { Assert.assertEquals(savedUser, foundUser); } + @Test + public void testFindUsersByIds() throws Exception { + loginTenantAdmin(); + List savedUsers = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + User user = createTenantAdminUser(); + savedUsers.add(doPost("/api/user", user, User.class)); + } + + String idsParam = savedUsers.stream() + .map(u -> u.getId().getId().toString()) + .collect(Collectors.joining(",")); + + User[] foundUsers = doGet("/api/users?userIds=" + idsParam, User[].class); + + Assert.assertNotNull(foundUsers); + Assert.assertEquals(savedUsers.size(), foundUsers.length); + + Map foundById = Arrays.stream(foundUsers) + .collect(Collectors.toMap(u -> u.getId().getId(), Function.identity())); + + for (User savedUser : savedUsers) { + User foundUser = foundById.get(savedUser.getId().getId()); + Assert.assertNotNull("User not found for id " + savedUser.getId().getId(), foundUser); + + foundUser.setAdditionalInfo(savedUser.getAdditionalInfo()); + Assert.assertEquals(savedUser, foundUser); + } + } + + @Test public void testSaveUserWithSameEmail() throws Exception { loginSysAdmin(); diff --git a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java b/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java index 6dacf50592..87ba0ec3e8 100644 --- a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/WidgetTypeControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/WidgetTypeControllerTest.java index e418db9857..a7a0349ebe 100644 --- a/application/src/test/java/org/thingsboard/server/controller/WidgetTypeControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/WidgetTypeControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/controller/WidgetsBundleControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/WidgetsBundleControllerTest.java index 3440f26ea8..917470eea1 100644 --- a/application/src/test/java/org/thingsboard/server/controller/WidgetsBundleControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/WidgetsBundleControllerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,12 +29,17 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.widget.WidgetsBundle; -import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -150,6 +155,37 @@ public class WidgetsBundleControllerTest extends AbstractControllerTest { Assert.assertEquals(savedWidgetsBundle, foundWidgetsBundle); } + @Test + public void testFindWidgetsBundlesByIds() throws Exception { + List savedWidgetsBundles = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + WidgetsBundle widgetsBundle = new WidgetsBundle(); + widgetsBundle.setTitle("My widgets bundle " + i); + savedWidgetsBundles.add(doPost("/api/widgetsBundle", widgetsBundle, WidgetsBundle.class)); + } + + String idsParam = savedWidgetsBundles.stream() + .map(wb -> wb.getId().getId().toString()) + .collect(Collectors.joining(",")); + + WidgetsBundle[] foundWidgetsBundles = + doGet("/api/widgetsBundles?widgetsBundleIds=" + idsParam, WidgetsBundle[].class); + + Assert.assertNotNull(foundWidgetsBundles); + Assert.assertEquals(savedWidgetsBundles.size(), foundWidgetsBundles.length); + + Map foundById = Arrays.stream(foundWidgetsBundles) + .collect(Collectors.toMap(wb -> wb.getId().getId(), Function.identity())); + + for (WidgetsBundle savedWidgetsBundle : savedWidgetsBundles) { + UUID id = savedWidgetsBundle.getId().getId(); + WidgetsBundle foundWidgetsBundle = foundById.get(id); + Assert.assertNotNull("WidgetsBundle not found for id " + id, foundWidgetsBundle); + Assert.assertEquals(savedWidgetsBundle, foundWidgetsBundle); + } + } + + @Test public void testDeleteWidgetsBundle() throws Exception { WidgetsBundle widgetsBundle = new WidgetsBundle(); diff --git a/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java b/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java index b50adfca63..053cb6808f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java index 84d879c993..3629d7ff49 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,7 @@ import com.google.protobuf.AbstractMessage; import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.MessageLite; import lombok.extern.slf4j.Slf4j; +import org.awaitility.Awaitility; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -102,6 +103,7 @@ import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.TreeMap; import java.util.UUID; @@ -738,4 +740,27 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { return rpc; } + protected void verifyEdgeDisconnected() { + verifyEdgeActiveFlag(false); + } + + protected void verifyEdgeConnected() { + verifyEdgeActiveFlag(true); + } + + private void verifyEdgeActiveFlag(boolean value) { + Awaitility.await() + .atMost(TIMEOUT, TimeUnit.SECONDS) + .until(() -> { + List> values = doGetAsyncTyped("/api/plugins/telemetry/EDGE/" + edge.getId() + + "/values/attributes/SERVER_SCOPE", new TypeReference<>() {}); + Optional> activeAttrOpt = values.stream().filter(att -> att.get("key").equals("active")).findFirst(); + if (activeAttrOpt.isEmpty()) { + return false; + } + Map activeAttr = activeAttrOpt.get(); + return Boolean.toString(value).equals(activeAttr.get("value").toString()); + }); + } + } diff --git a/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java new file mode 100644 index 0000000000..4b23ec3cf9 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java @@ -0,0 +1,216 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.edge; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.google.protobuf.AbstractMessage; +import com.google.protobuf.InvalidProtocolBufferException; +import org.junit.Assert; +import org.junit.Test; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.gen.edge.v1.UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE; + +@DaoSqlTest +public class AiModelEdgeTest extends AbstractEdgeTest { + + private static final String DEFAULT_AI_MODEL_NAME = "Edge Test AiModel"; + private static final String UPDATED_AI_MODEL_NAME = "Updated Edge Test AiModel"; + + @Test + public void testAiModel_create_update_delete_fromCloud() throws Exception { + // create AiModel + AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); + + edgeImitator.expectMessageAmount(1); + AiModel savedAiModel = doPost("/api/ai/model", aiModel, AiModel.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + AbstractMessage latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg); + AiModelUpdateMsg aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType()); + Assert.assertEquals(savedAiModel.getUuidId().getMostSignificantBits(), aiModelUpdateMsg.getIdMSB()); + Assert.assertEquals(savedAiModel.getUuidId().getLeastSignificantBits(), aiModelUpdateMsg.getIdLSB()); + AiModel aiModelFromMsg = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true); + Assert.assertNotNull(aiModelFromMsg); + + Assert.assertEquals(DEFAULT_AI_MODEL_NAME, aiModelFromMsg.getName()); + Assert.assertEquals(savedAiModel.getTenantId(), aiModelFromMsg.getTenantId()); + + // update AiModel + edgeImitator.expectMessageAmount(1); + savedAiModel.setName(UPDATED_AI_MODEL_NAME); + savedAiModel = doPost("/api/ai/model", savedAiModel, AiModel.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg); + aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage; + aiModelFromMsg = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true); + Assert.assertNotNull(aiModelFromMsg); + Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType()); + Assert.assertEquals(UPDATED_AI_MODEL_NAME, aiModelFromMsg.getName()); + + // delete AiModel + edgeImitator.expectMessageAmount(1); + doDelete("/api/ai/model/" + savedAiModel.getUuidId()) + .andExpect(status().isOk()); + Assert.assertTrue(edgeImitator.waitForMessages()); + + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg); + aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType()); + Assert.assertEquals(savedAiModel.getUuidId().getMostSignificantBits(), aiModelUpdateMsg.getIdMSB()); + Assert.assertEquals(savedAiModel.getUuidId().getLeastSignificantBits(), aiModelUpdateMsg.getIdLSB()); + } + + @Test + public void testAiModel_create_update_delete_toCloud() throws Exception { + // create + AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); + UUID uuid = Uuids.timeBased(); + UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + + checkAiModelOnCloud(uplinkMsg, uuid, aiModel.getName()); + + // update + aiModel.setName(UPDATED_AI_MODEL_NAME); + UplinkMsg updatedUplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE); + + checkAiModelOnCloud(updatedUplinkMsg, uuid, aiModel.getName()); + + // delete + UplinkMsg deleteUplinkMsg = getDeleteUplinkMsg(uuid); + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(deleteUplinkMsg); + Assert.assertTrue(edgeImitator.waitForResponses()); + + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> + doGet("/api/ai/model/" + uuid, AiModel.class, status().isNotFound()) + ); + } + + @Test + public void testAiModelToCloudWithNameThatAlreadyExistsOnCloud() throws Exception { + AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); + + edgeImitator.expectMessageAmount(1); + AiModel savedAiModel = doPost("/api/ai/model", aiModel, AiModel.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + UUID uuid = Uuids.timeBased(); + + UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.expectMessageAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsg); + + Assert.assertTrue(edgeImitator.waitForResponses()); + Assert.assertTrue(edgeImitator.waitForMessages()); + + Optional aiModelUpdateMsgOpt = edgeImitator.findMessageByType(AiModelUpdateMsg.class); + Assert.assertTrue(aiModelUpdateMsgOpt.isPresent()); + AiModelUpdateMsg latestAiModelUpdateMsg = aiModelUpdateMsgOpt.get(); + AiModel aiModelFromMsg = JacksonUtil.fromString(latestAiModelUpdateMsg.getEntity(), AiModel.class, true); + Assert.assertNotNull(aiModelFromMsg); + Assert.assertNotEquals(DEFAULT_AI_MODEL_NAME, aiModelFromMsg.getName()); + + Assert.assertNotEquals(savedAiModel.getUuidId(), uuid); + + AiModel aiModelFromCloud = doGet("/api/ai/model/" + uuid, AiModel.class); + Assert.assertNotNull(aiModelFromCloud); + Assert.assertNotEquals(DEFAULT_AI_MODEL_NAME, aiModelFromCloud.getName()); + } + + private AiModel createSimpleAiModel(String name) { + AiModel aiModel = new AiModel(); + aiModel.setTenantId(tenantId); + aiModel.setName(name); + aiModel.setConfiguration(OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig(null, "test-api-key")) + .modelId("gpt-4o") + .temperature(0.5) + .topP(0.3) + .frequencyPenalty(0.1) + .presencePenalty(0.2) + .maxOutputTokens(1000) + .timeoutSeconds(60) + .maxRetries(2) + .build()); + return aiModel; + } + + private UplinkMsg getDeleteUplinkMsg(UUID uuid) throws InvalidProtocolBufferException { + UplinkMsg.Builder upLinkMsgBuilder = UplinkMsg.newBuilder(); + AiModelUpdateMsg.Builder aiModelDeleteMsgBuilder = AiModelUpdateMsg.newBuilder(); + aiModelDeleteMsgBuilder.setMsgType(ENTITY_DELETED_RPC_MESSAGE); + aiModelDeleteMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + aiModelDeleteMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + testAutoGeneratedCodeByProtobuf(aiModelDeleteMsgBuilder); + + upLinkMsgBuilder.addAiModelUpdateMsg(aiModelDeleteMsgBuilder.build()); + testAutoGeneratedCodeByProtobuf(upLinkMsgBuilder); + + return upLinkMsgBuilder.build(); + } + + private UplinkMsg getUplinkMsg(UUID uuid, AiModel aiModel, UpdateMsgType updateMsgType) throws InvalidProtocolBufferException { + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + AiModelUpdateMsg.Builder aiModelUpdateMsgBuilder = AiModelUpdateMsg.newBuilder(); + aiModelUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + aiModelUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + aiModelUpdateMsgBuilder.setEntity(JacksonUtil.toString(aiModel)); + aiModelUpdateMsgBuilder.setMsgType(updateMsgType); + testAutoGeneratedCodeByProtobuf(aiModelUpdateMsgBuilder); + uplinkMsgBuilder.addAiModelUpdateMsg(aiModelUpdateMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + return uplinkMsgBuilder.build(); + } + + private void checkAiModelOnCloud(UplinkMsg uplinkMsg, UUID uuid, String resourceTitle) throws Exception { + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsg); + + Assert.assertTrue(edgeImitator.waitForResponses()); + + UplinkResponseMsg latestResponseMsg = edgeImitator.getLatestResponseMsg(); + Assert.assertTrue(latestResponseMsg.getSuccess()); + + AiModel aiModel = doGet("/api/ai/model/" + uuid, AiModel.class); + Assert.assertNotNull(aiModel); + Assert.assertEquals(resourceTitle, aiModel.getName()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/edge/AlarmEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AlarmEdgeTest.java index 3a88434103..000ef6d2d3 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AlarmEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AlarmEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java index 4d9840b936..96c6dd42b7 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,7 +15,6 @@ */ package org.thingsboard.server.edge; -import com.fasterxml.jackson.core.type.TypeReference; import com.google.protobuf.AbstractMessage; import org.junit.Assert; import org.junit.Test; @@ -28,8 +27,6 @@ import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; @@ -37,10 +34,11 @@ import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.gen.edge.v1.UplinkMsg; import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; -import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.TimeUnit; +import static org.awaitility.Awaitility.await; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @DaoSqlTest @@ -277,12 +275,10 @@ public class AssetEdgeTest extends AbstractEdgeTest { edgeImitator.expectResponsesAmount(1); edgeImitator.sendUplinkMsg(upLinkMsgBuilder.build()); Assert.assertTrue(edgeImitator.waitForResponses()); - AssetInfo assetInfo = doGet("/api/asset/info/" + savedAsset.getUuidId(), AssetInfo.class); - Assert.assertNotNull(assetInfo); - List edgeAssets = doGetTypedWithPageLink("/api/edge/" + edge.getUuidId() + "/assets?", - new TypeReference>() { - }, new PageLink(100)).getData(); - Assert.assertFalse(edgeAssets.contains(assetInfo)); + + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> + doGet("/api/asset/info/" + savedAsset.getUuidId(), AssetInfo.class, status().isNotFound()) + ); } private Asset saveAssetOnCloudAndVerifyDeliveryToEdge() throws Exception { diff --git a/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java index 4df305b2c5..68548558f7 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java index 268e19345c..6199657936 100644 --- a/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,10 +26,9 @@ import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; -import org.thingsboard.server.common.data.cf.configuration.Output; -import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.debug.DebugSettings; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.dao.service.DaoSqlTest; @@ -224,9 +223,8 @@ public class CalculatedFieldEdgeTest extends AbstractEdgeTest { config.setExpression("(T * 9/5) + 32"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("fahrenheitTemp"); - output.setType(OutputType.TIME_SERIES); output.setDecimalsByDefault(2); config.setOutput(output); diff --git a/application/src/test/java/org/thingsboard/server/edge/CustomerEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/CustomerEdgeTest.java index 701c6ec517..f3d12675a3 100644 --- a/application/src/test/java/org/thingsboard/server/edge/CustomerEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/CustomerEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java index 0730907c26..9c70db267e 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -40,10 +40,11 @@ import org.thingsboard.server.gen.edge.v1.ResourceUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.gen.edge.v1.UplinkMsg; -import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.TimeUnit; +import static org.awaitility.Awaitility.await; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @DaoSqlTest @@ -264,7 +265,7 @@ public class DashboardEdgeTest extends AbstractEdgeTest { } @Test - public void testSendDeleteEntityViewOnEdgeToCloud() throws Exception { + public void testSendDeleteDashboardOnEdgeToCloud() throws Exception { Dashboard savedDashboard = saveDashboardOnCloudAndVerifyDeliveryToEdge(); UplinkMsg.Builder upLinkMsgBuilder = UplinkMsg.newBuilder(); @@ -281,12 +282,10 @@ public class DashboardEdgeTest extends AbstractEdgeTest { edgeImitator.expectResponsesAmount(1); edgeImitator.sendUplinkMsg(upLinkMsgBuilder.build()); Assert.assertTrue(edgeImitator.waitForResponses()); - DashboardInfo dashboardInfo = doGet("/api/dashboard/info/" + savedDashboard.getUuidId(), DashboardInfo.class); - Assert.assertNotNull(dashboardInfo); - List edgeAssets = doGetTypedWithPageLink("/api/edge/" + edge.getUuidId() + "/dashboards?", - new TypeReference>() { - }, new PageLink(100)).getData(); - Assert.assertFalse(edgeAssets.contains(dashboardInfo)); + + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> + doGet("/api/dashboard/info/" + savedDashboard.getUuidId(), DashboardInfo.class, status().isNotFound()) + ); } private Dashboard saveDashboardOnCloudAndVerifyDeliveryToEdge() throws Exception { @@ -300,10 +299,10 @@ public class DashboardEdgeTest extends AbstractEdgeTest { Assert.assertTrue(edgeImitator.waitForMessages()); Optional dashboardUpdateMsgOpt = edgeImitator.findMessageByType(DashboardUpdateMsg.class); Assert.assertTrue(dashboardUpdateMsgOpt.isPresent()); - DashboardUpdateMsg entityViewUpdateMsg = dashboardUpdateMsgOpt.get(); - Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, entityViewUpdateMsg.getMsgType()); - Assert.assertEquals(savedDashboard.getUuidId().getMostSignificantBits(), entityViewUpdateMsg.getIdMSB()); - Assert.assertEquals(savedDashboard.getUuidId().getLeastSignificantBits(), entityViewUpdateMsg.getIdLSB()); + DashboardUpdateMsg dashboardUpdateMsg = dashboardUpdateMsgOpt.get(); + Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, dashboardUpdateMsg.getMsgType()); + Assert.assertEquals(savedDashboard.getUuidId().getMostSignificantBits(), dashboardUpdateMsg.getIdMSB()); + Assert.assertEquals(savedDashboard.getUuidId().getLeastSignificantBits(), dashboardUpdateMsg.getIdLSB()); return savedDashboard; } diff --git a/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java index da8e7563e0..9292d5bc06 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -49,8 +49,6 @@ 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.ota.OtaPackageType; -import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; @@ -79,6 +77,7 @@ import java.util.Random; import java.util.UUID; import java.util.concurrent.TimeUnit; +import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -413,12 +412,10 @@ public class DeviceEdgeTest extends AbstractEdgeTest { edgeImitator.expectResponsesAmount(1); edgeImitator.sendUplinkMsg(upLinkMsgBuilder.build()); Assert.assertTrue(edgeImitator.waitForResponses()); - DeviceInfo deviceInfo = doGet("/api/device/info/" + savedDevice.getUuidId(), DeviceInfo.class); - Assert.assertNotNull(deviceInfo); - List edgeDevices = doGetTypedWithPageLink("/api/edge/" + edge.getUuidId() + "/devices?", - new TypeReference>() { - }, new PageLink(100)).getData(); - Assert.assertFalse(edgeDevices.contains(deviceInfo)); + + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> + doGet("/api/device/info/" + savedDevice.getUuidId(), DeviceInfo.class, status().isNotFound()) + ); } @Test @@ -672,8 +669,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest { .atMost(10, TimeUnit.SECONDS) .until(() -> { String urlTemplate = "/api/plugins/telemetry/DEVICE/" + device.getId() + "/keys/attributes/" + scope; - List actualKeys = doGetAsyncTyped(urlTemplate, new TypeReference<>() { - }); + List actualKeys = doGetAsyncTyped(urlTemplate, new TypeReference<>() {}); return actualKeys != null && !actualKeys.isEmpty() && actualKeys.contains(expectedKey); }); @@ -894,18 +890,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest { ObjectNode attributes = JacksonUtil.newObjectNode(); attributes.put("active", true); doPost("/api/plugins/telemetry/EDGE/" + edge.getId() + "/attributes/" + DataConstants.SERVER_SCOPE, attributes); - Awaitility.await() - .atMost(TIMEOUT, TimeUnit.SECONDS) - .until(() -> { - List> values = doGetAsyncTyped("/api/plugins/telemetry/EDGE/" + edge.getId() + - "/values/attributes/SERVER_SCOPE", new TypeReference<>() {}); - Optional> activeAttrOpt = values.stream().filter(att -> att.get("key").equals("active")).findFirst(); - if (activeAttrOpt.isEmpty()) { - return false; - } - Map activeAttr = activeAttrOpt.get(); - return "true".equals(activeAttr.get("value").toString()); - }); + verifyEdgeConnected(); } } diff --git a/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java index a29d7a4672..f44b4e295e 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -151,14 +151,20 @@ public class DeviceProfileEdgeTest extends AbstractEdgeTest { // delete profile when edge is offline edgeImitator.disconnect(); + verifyEdgeDisconnected(); + doDelete("/api/deviceProfile/" + deviceProfile.getUuidId()) .andExpect(status().isOk()); - edgeImitator.connect(); // 25 sync message - // + 2 RuleChain and RuleChainMetadata - // + 1 delete DeviceProfile + // + 1 RuleChain Added + // + 1 RuleChainMetadata Added + // + 1 DeviceProfile Delete edgeImitator.expectMessageAmount(SYNC_MESSAGE_COUNT + 3); + + edgeImitator.connect(); + verifyEdgeConnected(); + Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); diff --git a/application/src/test/java/org/thingsboard/server/edge/EdgeStatsIntegrationTest.java b/application/src/test/java/org/thingsboard/server/edge/EdgeStatsIntegrationTest.java new file mode 100644 index 0000000000..e6cad9da6a --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/edge/EdgeStatsIntegrationTest.java @@ -0,0 +1,146 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.edge; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.junit.Assert; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.dao.edge.stats.EdgeStatsCounterService; +import org.thingsboard.server.dao.edge.stats.EdgeStatsKey; +import org.thingsboard.server.dao.edge.stats.MsgCounters; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.service.edge.stats.EdgeStatsService; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.thingsboard.server.dao.edge.stats.EdgeStatsKey.DOWNLINK_MSGS_ADDED; +import static org.thingsboard.server.dao.edge.stats.EdgeStatsKey.DOWNLINK_MSGS_PERMANENTLY_FAILED; +import static org.thingsboard.server.dao.edge.stats.EdgeStatsKey.DOWNLINK_MSGS_PUSHED; +import static org.thingsboard.server.dao.edge.stats.EdgeStatsKey.DOWNLINK_MSGS_TMP_FAILED; + +@DaoSqlTest +@Slf4j +public class EdgeStatsIntegrationTest extends AbstractEdgeTest { + + private static final String STATISTICS_DEVICE_PROFILE = "STATISTICS"; + + private static final long EXPECTED_MSGS_ADDED = 6L; + private static final long EXPECTED_MSGS_PUSHED = 6L; + private static final long EXPECTED_MSGS_PERMANENTLY_FAILED = 0L; + private static final long EXPECTED_MSGS_TMP_FAILED = 0L; + + @Autowired + private EdgeStatsService edgeStatsService; + @Autowired + private EdgeStatsCounterService statsCounterService; + + @Test + public void testReportStats() throws Exception { + // GIVEN + simulateEdgeEventsAddedDownlinkPushed(); + + // Await Edge Counters Updated + await().atMost(10, TimeUnit.SECONDS).pollInterval(Duration.ofMillis(200)).untilAsserted(() -> { + MsgCounters counters = statsCounterService.getMsgCountersByEdge().get(edge.getId()); + assertEquals(EXPECTED_MSGS_ADDED, counters.getMsgsAdded().get()); + assertEquals(EXPECTED_MSGS_PUSHED, counters.getMsgsPushed().get()); + assertEquals(EXPECTED_MSGS_PERMANENTLY_FAILED, counters.getMsgsPermanentlyFailed().get()); + assertEquals(EXPECTED_MSGS_TMP_FAILED, counters.getMsgsTmpFailed().get()); + }); + + // WHEN + edgeStatsService.reportStats(); + + // THEN + await().atMost(10, TimeUnit.SECONDS).pollInterval(Duration.ofMillis(200)).untilAsserted(() -> { + List actualStats = fetchLatestStats(); + assertEquals(EXPECTED_MSGS_ADDED, getStatsLongValue(actualStats, DOWNLINK_MSGS_ADDED)); + assertEquals(EXPECTED_MSGS_PUSHED, getStatsLongValue(actualStats, DOWNLINK_MSGS_PUSHED)); + assertEquals(EXPECTED_MSGS_PERMANENTLY_FAILED, getStatsLongValue(actualStats, DOWNLINK_MSGS_PERMANENTLY_FAILED)); + assertEquals(EXPECTED_MSGS_TMP_FAILED, getStatsLongValue(actualStats, DOWNLINK_MSGS_TMP_FAILED)); + }); + } + + private long getStatsLongValue(List stats, EdgeStatsKey key) { + return stats.stream().filter(e -> e.getKey().equals(key.getKey())).findFirst().get().getLongValue().orElse(0L); + } + + private List fetchLatestStats() throws ExecutionException, InterruptedException { + return tsService.findLatest( + tenantId, + edge.getId(), + Arrays.stream(EdgeStatsKey.values()).map(EdgeStatsKey::getKey).toList()).get(); + } + + private void simulateEdgeEventsAddedDownlinkPushed() throws InterruptedException, ExecutionException { + statsCounterService.clear(edge.getId()); + + // Save device and assign to edge + // 2 DOWNLINK_MSGS_ADDED, EdgeEvents: [{DEVICE_PROFILE: ADDED}, {DEVICE: ASSIGNED_TO_EDGE}] + // 2 DOWNLINK_MSGS_PUSHED, Downlinks: [{deviceProfileUpdateMsg}, {deviceUpdateMsg, deviceProfileUpdateMsg, deviceCredentialsUpdateMsg}] + edgeImitator.expectMessageAmount(4); + Device savedDevice = saveDevice("StatisticDevice", STATISTICS_DEVICE_PROFILE); + doPost("/api/edge/" + edge.getUuidId() + "/device/" + savedDevice.getUuidId(), Device.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + // Save asset and assign to edge + // 1 DOWNLINK_MSGS_ADDED, EdgeEvents: [{ASSET: ASSIGNED_TO_EDGE}] + // 1 DOWNLINK_MSGS_PUSHED, Downlinks: [{assetUpdateMsg, assetProfileUpdateMsg}] + edgeImitator.expectMessageAmount(2); + Asset savedAsset = saveAsset("Edge Asset"); + doPost("/api/edge/" + edge.getUuidId() + + "/asset/" + savedAsset.getUuidId(), Asset.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + // Create customer and assign edge to the customer + // 2 DOWNLINK_MSGS_ADDED, EdgeEvents: [{CUSTOMER: ADDED}, {EDGE: ASSIGNED_TO_CUSTOMER}] + // 2 DOWNLINK_MSGS_PUSHED, Downlinks: [{customerUpdateMsg}, {edgeConfiguration}] + edgeImitator.expectMessageAmount(2); + Customer customer = new Customer(); + customer.setTitle("Edge Customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + doPost("/api/customer/" + savedCustomer.getUuidId() + + "/edge/" + edge.getUuidId(), Edge.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + // Send device telemetry downlink for the device + // 1 DOWNLINK_MSGS_ADDED, EdgeEvents: [{DEVICE: TIMESERIES_UPDATED}] + // 1 DOWNLINK_MSGS_PUSHED, Downlinks: [{entityData}] + edgeImitator.expectMessageAmount(1); + String timeseriesData = "{\"data\":{\"temperature\":25},\"ts\":" + System.currentTimeMillis() + "}"; + JsonNode timeseriesEntityData = JacksonUtil.toJsonNode(timeseriesData); + EdgeEvent edgeEvent = constructEdgeEvent(tenantId, edge.getId(), EdgeEventActionType.TIMESERIES_UPDATED, savedDevice.getId().getId(), EdgeEventType.DEVICE, timeseriesEntityData); + edgeEventService.saveAsync(edgeEvent).get(); + Assert.assertTrue(edgeImitator.waitForMessages()); + } +} diff --git a/application/src/test/java/org/thingsboard/server/edge/EdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/EdgeTest.java index ff9fb2fcd7..334680f7a7 100644 --- a/application/src/test/java/org/thingsboard/server/edge/EdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/EdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java index 875a83e3f5..ce7b915457 100644 --- a/application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,7 +15,6 @@ */ package org.thingsboard.server.edge; -import com.fasterxml.jackson.core.type.TypeReference; import com.google.protobuf.AbstractMessage; import com.google.protobuf.InvalidProtocolBufferException; import org.junit.Assert; @@ -31,8 +30,6 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; -import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg; import org.thingsboard.server.gen.edge.v1.EntityViewsRequestMsg; @@ -40,10 +37,11 @@ import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.gen.edge.v1.UplinkMsg; import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; -import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.TimeUnit; +import static org.awaitility.Awaitility.await; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @DaoSqlTest @@ -256,12 +254,9 @@ public class EntityViewEdgeTest extends AbstractEdgeTest { edgeImitator.expectResponsesAmount(1); edgeImitator.sendUplinkMsg(upLinkMsgBuilder.build()); Assert.assertTrue(edgeImitator.waitForResponses()); - EntityViewInfo entityViewInfo = doGet("/api/entityView/info/" + savedEntityView.getUuidId(), EntityViewInfo.class); - Assert.assertNotNull(entityViewInfo); - List edgeAssets = doGetTypedWithPageLink("/api/edge/" + edge.getUuidId() + "/entityViews?", - new TypeReference>() { - }, new PageLink(100)).getData(); - Assert.assertFalse(edgeAssets.contains(entityViewInfo)); + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> + doGet("/api/entityView/info/" + savedEntityView.getUuidId(), EntityViewInfo.class, status().isNotFound()) + ); } private void verifyEntityViewUpdateMsg(EntityView entityView, Device device) throws InvalidProtocolBufferException { diff --git a/application/src/test/java/org/thingsboard/server/edge/NotificationEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/NotificationEdgeTest.java index 99b59dc723..d5458ce793 100644 --- a/application/src/test/java/org/thingsboard/server/edge/NotificationEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/NotificationEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/edge/OAuth2EdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/OAuth2EdgeTest.java index 9289b30fb2..5fb416db2c 100644 --- a/application/src/test/java/org/thingsboard/server/edge/OAuth2EdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/OAuth2EdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/edge/OtaPackageEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/OtaPackageEdgeTest.java index 9b7a6b83a4..17343b208d 100644 --- a/application/src/test/java/org/thingsboard/server/edge/OtaPackageEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/OtaPackageEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/edge/QueueEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/QueueEdgeTest.java index 4af0515838..865c843c7f 100644 --- a/application/src/test/java/org/thingsboard/server/edge/QueueEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/QueueEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/edge/RelationEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/RelationEdgeTest.java index 383c76566e..054f67585e 100644 --- a/application/src/test/java/org/thingsboard/server/edge/RelationEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/RelationEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/edge/ResourceEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/ResourceEdgeTest.java index 74a1993ecc..b06ddf1f8c 100644 --- a/application/src/test/java/org/thingsboard/server/edge/ResourceEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/ResourceEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java index 7c029e0471..a9a3a8d69d 100644 --- a/application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/edge/TelemetryEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/TelemetryEdgeTest.java index 36d9ed5308..5818179f45 100644 --- a/application/src/test/java/org/thingsboard/server/edge/TelemetryEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/TelemetryEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -223,8 +223,10 @@ public class TelemetryEdgeTest extends AbstractEdgeTest { device.getId().getId(), EdgeEventType.DEVICE, timeseriesEntityData); edgeEventService.saveAsync(failedEdgeEvent).get(); + // add unique body to avoid merge and filter by device id in edge service (mergeAndFilterDownlinkDuplicates) + JsonNode uniqueBody = JacksonUtil.toJsonNode("{\"idx\":" + idx + "}"); EdgeEvent successEdgeEvent = constructEdgeEvent(tenantId, edge.getId(), EdgeEventActionType.UPDATED, - device.getId().getId(), EdgeEventType.DEVICE, null); + device.getId().getId(), EdgeEventType.DEVICE, uniqueBody); edgeEventService.saveAsync(successEdgeEvent).get(); } diff --git a/application/src/test/java/org/thingsboard/server/edge/TenantEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/TenantEdgeTest.java index a48798fb3b..283e8bb66b 100644 --- a/application/src/test/java/org/thingsboard/server/edge/TenantEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/TenantEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/edge/TenantProfileEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/TenantProfileEdgeTest.java index c7e9ed1575..a5a5fe9317 100644 --- a/application/src/test/java/org/thingsboard/server/edge/TenantProfileEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/TenantProfileEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java index 05debdc015..f344335748 100644 --- a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,15 +15,22 @@ */ package org.thingsboard.server.edge; -import com.google.protobuf.AbstractMessage; +import com.fasterxml.jackson.databind.JsonNode; import org.junit.Assert; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.web.servlet.ResultMatcher; +import org.testcontainers.shaded.org.awaitility.Awaitility; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.UserCredentialsId; +import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.dao.service.DaoSqlTest; @@ -32,11 +39,15 @@ import org.thingsboard.server.gen.edge.v1.UplinkMsg; import org.thingsboard.server.gen.edge.v1.UserCredentialsRequestMsg; import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; +import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; import org.thingsboard.server.service.security.model.ChangePasswordRequest; import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.dao.user.UserServiceImpl.DEFAULT_TOKEN_LENGTH; @DaoSqlTest public class UserEdgeTest extends AbstractEdgeTest { @@ -44,216 +55,304 @@ public class UserEdgeTest extends AbstractEdgeTest { @Autowired private BCryptPasswordEncoder passwordEncoder; + private static final String DEFAULT_FIRST_NAME = "Boris"; + private static final String DEFAULT_LAST_NAME = "Johnson"; + private static final String UPDATED_LAST_NAME = "Borisov"; + private static final String DEFAULT_TENANT_ADMIN_EMAIL = "tenantAdmin@thingsboard.org"; + private static final String DEFAULT_CUSTOMER_USER_EMAIL = "customerUser@thingsboard.org"; + @Test public void testCreateUpdateDeleteTenantUser() throws Exception { // create user - edgeImitator.expectMessageAmount(3); - User newTenantAdmin = new User(); - newTenantAdmin.setAuthority(Authority.TENANT_ADMIN); - newTenantAdmin.setTenantId(tenantId); - newTenantAdmin.setEmail("tenantAdmin@thingsboard.org"); - newTenantAdmin.setFirstName("Boris"); - newTenantAdmin.setLastName("Johnson"); - User savedTenantAdmin = createUser(newTenantAdmin, "tenant"); - Assert.assertTrue(edgeImitator.waitForMessages()); // wait 3 messages - x1 user update msg and x2 user credentials update msgs (create + authenticate user) - Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); - Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); - Optional userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class); - Assert.assertTrue(userUpdateMsgOpt.isPresent()); - UserUpdateMsg userUpdateMsg = userUpdateMsgOpt.get(); - User userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); - Assert.assertNotNull(userMsg); - Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, userUpdateMsg.getMsgType()); - Assert.assertEquals(savedTenantAdmin.getId(), userMsg.getId()); - Assert.assertEquals(savedTenantAdmin.getAuthority(), userMsg.getAuthority()); - Assert.assertEquals(savedTenantAdmin.getEmail(), userMsg.getEmail()); - Assert.assertEquals(savedTenantAdmin.getFirstName(), userMsg.getFirstName()); - Assert.assertEquals(savedTenantAdmin.getLastName(), userMsg.getLastName()); - Optional userCredentialsUpdateMsgOpt = edgeImitator.findMessageByType(UserCredentialsUpdateMsg.class); - Assert.assertTrue(userCredentialsUpdateMsgOpt.isPresent()); + User newTenantAdmin = buildUser(Authority.TENANT_ADMIN, null); + User savedTenantAdmin = createAndVerifyUserOnEdge(newTenantAdmin); // update user - edgeImitator.expectMessageAmount(2); - savedTenantAdmin.setLastName("Borisov"); - savedTenantAdmin = doPost("/api/user", savedTenantAdmin, User.class); - Assert.assertTrue(edgeImitator.waitForMessages()); - userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class); - Assert.assertTrue(userUpdateMsgOpt.isPresent()); - userUpdateMsg = userUpdateMsgOpt.get(); - userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); - Assert.assertNotNull(userMsg); - Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, userUpdateMsg.getMsgType()); - Assert.assertEquals(savedTenantAdmin.getLastName(), userMsg.getLastName()); + updateAndVerifyUserLastName(savedTenantAdmin); // update user credentials login(savedTenantAdmin.getEmail(), "tenant"); + updateAndVerifyUserCredentials(savedTenantAdmin); + loginTenantAdmin(); - edgeImitator.expectMessageAmount(1); - ChangePasswordRequest changePasswordRequest = new ChangePasswordRequest(); - changePasswordRequest.setCurrentPassword("tenant"); - changePasswordRequest.setNewPassword("newTenant"); - doPost("/api/auth/changePassword", changePasswordRequest); - Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg); - UserCredentialsUpdateMsg userCredentialsUpdateMsg = (UserCredentialsUpdateMsg) latestMessage; - UserCredentials userCredentialsMsg = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true); - Assert.assertNotNull(userCredentialsMsg); - Assert.assertEquals(savedTenantAdmin.getId(), userCredentialsMsg.getUserId()); - Assert.assertTrue(passwordEncoder.matches(changePasswordRequest.getNewPassword(), userCredentialsMsg.getPassword())); + // delete user + deleteAndVerifyUser(savedTenantAdmin); + } + + @Test + public void testCreateUpdateDeleteCustomerUser() throws Exception { + // create customer + Customer savedCustomer = createAndAssignCustomerToEdge(); + // create user + User customerUser = buildUser(Authority.CUSTOMER_USER, savedCustomer.getId()); + User savedCustomerUser = createAndVerifyUserOnEdge(customerUser); + + // update user + updateAndVerifyUserLastName(savedCustomerUser); + + // update user credentials + login(savedCustomerUser.getEmail(), "customer"); + updateAndVerifyUserCredentials(savedCustomerUser); loginTenantAdmin(); // delete user + deleteAndVerifyUser(savedCustomerUser); + } + + @Test + public void testSendUserToCloudFromEdge() throws Exception { + // create customer + Customer savedCustomer = createAndAssignCustomerToEdge(); + + // create uplinkMsg with user and userCredentials + UserId userId = new UserId(UUID.randomUUID()); + UserCredentialsId userCredentialsId = new UserCredentialsId(UUID.randomUUID()); + UplinkMsg uplinkMsg = buildUserUplinkMsg(userId, savedCustomer.getId(), userCredentialsId); + + User userFromCloud = verifyMsgOnCloud(uplinkMsg, userId, false); + assertUserCredentialsFlags(userFromCloud, false, false); + + // create uplinkMsg with enabled userCredentials + UplinkMsg uplinkMsgWithEnabledCredentials = constructUserCredentialsUplinkMsg(userCredentialsId, userId); + + User cloudUserWithCredentials = verifyMsgOnCloud(uplinkMsgWithEnabledCredentials, userId, false); + assertUserCredentialsFlags(cloudUserWithCredentials, true, true); + + // create uplinkMsg with user the same email + UserId secondUserId = new UserId(UUID.randomUUID()); + UserCredentialsId secondCredentialsId = new UserCredentialsId(UUID.randomUUID()); + UplinkMsg uplinkMsgForUserExistingEmail = buildUserUplinkMsg(secondUserId, savedCustomer.getId(), secondCredentialsId); + + verifyMsgOnCloud(uplinkMsgForUserExistingEmail, secondUserId, true); + } + + @Test + public void testSendUserCredentialsRequestToCloud() throws Exception { + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + UserCredentialsRequestMsg.Builder userCredentialsRequestMsgBuilder = UserCredentialsRequestMsg.newBuilder(); + userCredentialsRequestMsgBuilder.setUserIdMSB(tenantAdminUserId.getId().getMostSignificantBits()); + userCredentialsRequestMsgBuilder.setUserIdLSB(tenantAdminUserId.getId().getLeastSignificantBits()); + testAutoGeneratedCodeByProtobuf(userCredentialsRequestMsgBuilder); + uplinkMsgBuilder.addUserCredentialsRequestMsg(userCredentialsRequestMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + edgeImitator.expectResponsesAmount(1); edgeImitator.expectMessageAmount(1); - doDelete("/api/user/" + savedTenantAdmin.getUuidId()) - .andExpect(status().isOk()); + edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); + Assert.assertTrue(edgeImitator.waitForResponses()); Assert.assertTrue(edgeImitator.waitForMessages()); - latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof UserUpdateMsg); - userUpdateMsg = (UserUpdateMsg) latestMessage; - Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, userUpdateMsg.getMsgType()); - Assert.assertEquals(savedTenantAdmin.getUuidId().getMostSignificantBits(), userUpdateMsg.getIdMSB()); - Assert.assertEquals(savedTenantAdmin.getUuidId().getLeastSignificantBits(), userUpdateMsg.getIdLSB()); + + UserCredentialsUpdateMsg userCredentialsUpdateMsg = getLatestUserCredentialsUpdateMsg(); + UserCredentials userCredentialsMsg = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true); + Assert.assertNotNull(userCredentialsMsg); + Assert.assertEquals(tenantAdminUserId, userCredentialsMsg.getUserId()); + + testAutoGeneratedCodeByProtobuf(userCredentialsUpdateMsg); } @Test - public void testCreateUpdateDeleteCustomerUser() throws Exception { + public void testSendUserDeleteFromEdgeToCloud() throws Exception { // create customer + Customer savedCustomer = createAndAssignCustomerToEdge(); + + // create user + User customerUser = buildUser(Authority.CUSTOMER_USER, savedCustomer.getId()); + User savedCustomerUser = createAndVerifyUserOnEdge(customerUser); + + // simulate user removal event from edge to cloud + UserUpdateMsg.Builder userUpdateMsg = UserUpdateMsg.newBuilder().setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(savedCustomerUser.getUuidId().getMostSignificantBits()) + .setIdLSB(savedCustomerUser.getUuidId().getLeastSignificantBits()); + + UplinkMsg uplink = UplinkMsg.newBuilder() + .setUplinkMsgId(EdgeUtils.nextPositiveInt()) + .addUserUpdateMsg(userUpdateMsg).build(); + + testAutoGeneratedCodeByProtobuf(userUpdateMsg); + + // expect edge message sent & cloud message response + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(uplink); + Assert.assertTrue(edgeImitator.waitForResponses()); + + loginTenantAdmin(); + Awaitility.await().atMost(10, TimeUnit.SECONDS) + .until(() -> { + try { + doGet("/api/user/" + savedCustomerUser.getId(), User.class, status().isNotFound()); + return true; + } catch (Throwable ex) { + return false; + } + }); + } + + private Customer createAndAssignCustomerToEdge() throws Exception { edgeImitator.expectMessageAmount(1); Customer customer = new Customer(); customer.setTitle("Edge Customer"); Customer savedCustomer = doPost("/api/customer", customer, Customer.class); Assert.assertFalse(edgeImitator.waitForMessages(5)); - // assign edge to customer edgeImitator.expectMessageAmount(2); - doPost("/api/customer/" + savedCustomer.getUuidId() - + "/edge/" + edge.getUuidId(), Edge.class); + doPost("/api/customer/" + savedCustomer.getUuidId() + "/edge/" + edge.getUuidId(), Edge.class); Assert.assertTrue(edgeImitator.waitForMessages()); - // create user + return savedCustomer; + } + + private User buildUser(Authority authority, CustomerId customerId) { + User user = new User(); + + user.setAuthority(authority); + user.setTenantId(tenantId); + user.setCustomerId(customerId); + user.setEmail(authority == Authority.TENANT_ADMIN ? DEFAULT_TENANT_ADMIN_EMAIL : DEFAULT_CUSTOMER_USER_EMAIL); + user.setFirstName(DEFAULT_FIRST_NAME); + user.setLastName(DEFAULT_LAST_NAME); + + return user; + } + + private User createAndVerifyUserOnEdge(User user) throws Exception { + String password = user.getAuthority() == Authority.TENANT_ADMIN ? "tenant" : "customer"; + + // wait 3 messages - x1 user update msg and x2 user credentials update msgs (create + authenticate user) edgeImitator.expectMessageAmount(3); - User customerUser = new User(); - customerUser.setAuthority(Authority.CUSTOMER_USER); - customerUser.setTenantId(tenantId); - customerUser.setCustomerId(savedCustomer.getId()); - customerUser.setEmail("customerUser@thingsboard.org"); - customerUser.setFirstName("John"); - customerUser.setLastName("Edwards"); - User savedCustomerUser = createUser(customerUser, "customer"); - Assert.assertTrue(edgeImitator.waitForMessages()); // wait 3 messages - x1 user update msg and x2 user credentials update msgs (create + authenticate user) + User savedUser = createUser(user, password); + Assert.assertTrue(edgeImitator.waitForMessages()); Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); - Optional userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class); - Assert.assertTrue(userUpdateMsgOpt.isPresent()); - UserUpdateMsg userUpdateMsg = userUpdateMsgOpt.get(); + + UserUpdateMsg userUpdateMsg = getLatestUserUpdateMsg(); User userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); Assert.assertNotNull(userMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, userUpdateMsg.getMsgType()); - Assert.assertEquals(savedCustomerUser.getId(), userMsg.getId()); - Assert.assertEquals(savedCustomerUser.getCustomerId(), userMsg.getCustomerId()); - Assert.assertEquals(savedCustomerUser.getAuthority(), userMsg.getAuthority()); - Assert.assertEquals(savedCustomerUser.getEmail(), userMsg.getEmail()); - Assert.assertEquals(savedCustomerUser.getFirstName(), userMsg.getFirstName()); - Assert.assertEquals(savedCustomerUser.getLastName(), userMsg.getLastName()); + Assert.assertEquals(savedUser.getId(), userMsg.getId()); + Assert.assertEquals(savedUser.getCustomerId(), userMsg.getCustomerId()); + Assert.assertEquals(savedUser.getAuthority(), userMsg.getAuthority()); + Assert.assertEquals(savedUser.getEmail(), userMsg.getEmail()); + Assert.assertEquals(savedUser.getFirstName(), userMsg.getFirstName()); + Assert.assertEquals(savedUser.getLastName(), userMsg.getLastName()); + + return savedUser; + } + + private void updateAndVerifyUserLastName(User user) throws Exception { + user.setLastName(UPDATED_LAST_NAME); - // update user edgeImitator.expectMessageAmount(2); - savedCustomerUser.setLastName("Addams"); - savedCustomerUser = doPost("/api/user", savedCustomerUser, User.class); + doPost("/api/user", user, User.class); Assert.assertTrue(edgeImitator.waitForMessages()); - userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class); - Assert.assertTrue(userUpdateMsgOpt.isPresent()); - userUpdateMsg = userUpdateMsgOpt.get(); - userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); - Assert.assertNotNull(userMsg); + + UserUpdateMsg userUpdateMsg = getLatestUserUpdateMsg(); + User userFromMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); + Assert.assertNotNull(userFromMsg); Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, userUpdateMsg.getMsgType()); - Assert.assertEquals(savedCustomerUser.getLastName(), userMsg.getLastName()); + Assert.assertEquals(UPDATED_LAST_NAME, userFromMsg.getLastName()); + } - // update user credentials - login(savedCustomerUser.getEmail(), "customer"); + private void updateAndVerifyUserCredentials(User user) throws Exception { + String password = user.getAuthority() == Authority.TENANT_ADMIN ? "tenant" : "customer"; + String newPassword = "new" + password; edgeImitator.expectMessageAmount(1); ChangePasswordRequest changePasswordRequest = new ChangePasswordRequest(); - changePasswordRequest.setCurrentPassword("customer"); - changePasswordRequest.setNewPassword("newCustomer"); + changePasswordRequest.setCurrentPassword(password); + changePasswordRequest.setNewPassword(newPassword); doPost("/api/auth/changePassword", changePasswordRequest); Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg); - UserCredentialsUpdateMsg userCredentialsUpdateMsg = (UserCredentialsUpdateMsg) latestMessage; - UserCredentials userCredentialsMsg = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true); - Assert.assertNotNull(userCredentialsMsg); - Assert.assertEquals(savedCustomerUser.getId(), userCredentialsMsg.getUserId()); - Assert.assertTrue(passwordEncoder.matches(changePasswordRequest.getNewPassword(), userCredentialsMsg.getPassword())); - loginTenantAdmin(); + UserCredentialsUpdateMsg msg = getLatestUserCredentialsUpdateMsg(); + UserCredentials creds = JacksonUtil.fromString(msg.getEntity(), UserCredentials.class, true); + Assert.assertNotNull(creds); + Assert.assertEquals(user.getId(), creds.getUserId()); + Assert.assertTrue(passwordEncoder.matches(newPassword, creds.getPassword())); + } - // delete user + private void deleteAndVerifyUser(User savedTenantAdmin) throws Exception { edgeImitator.expectMessageAmount(1); - doDelete("/api/user/" + savedCustomerUser.getUuidId()) + doDelete("/api/user/" + savedTenantAdmin.getUuidId()) .andExpect(status().isOk()); Assert.assertTrue(edgeImitator.waitForMessages()); - latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof UserUpdateMsg); - userUpdateMsg = (UserUpdateMsg) latestMessage; + + UserUpdateMsg userUpdateMsg = getLatestUserUpdateMsg(); Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, userUpdateMsg.getMsgType()); - Assert.assertEquals(savedCustomerUser.getUuidId().getMostSignificantBits(), userUpdateMsg.getIdMSB()); - Assert.assertEquals(savedCustomerUser.getUuidId().getLeastSignificantBits(), userUpdateMsg.getIdLSB()); + Assert.assertEquals(savedTenantAdmin.getUuidId().getMostSignificantBits(), userUpdateMsg.getIdMSB()); + Assert.assertEquals(savedTenantAdmin.getUuidId().getLeastSignificantBits(), userUpdateMsg.getIdLSB()); + } + + private UplinkMsg buildUserUplinkMsg(UserId userId, CustomerId customerId, UserCredentialsId userCredentialsUuid) { + User customerUser = buildUser(Authority.CUSTOMER_USER, customerId); + customerUser.setId(userId); + UserCredentials userCredentials = buildCredentials(userCredentialsUuid, userId, false); + userCredentials.setActivateToken(StringUtils.randomAlphanumeric(DEFAULT_TOKEN_LENGTH)); + UserUpdateMsg userUpdateMsg = EdgeMsgConstructorUtils.constructUserUpdatedMsg(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, customerUser); + UserCredentialsUpdateMsg userCredentialsMsg = EdgeMsgConstructorUtils.constructUserCredentialsUpdatedMsg(userCredentials); + + return UplinkMsg.newBuilder() + .addUserUpdateMsg(userUpdateMsg) + .addUserCredentialsUpdateMsg(userCredentialsMsg) + .build(); } - @Test - public void testSendUserCredentialsRequestToCloud() throws Exception { - UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); - UserCredentialsRequestMsg.Builder userCredentialsRequestMsgBuilder = UserCredentialsRequestMsg.newBuilder(); - userCredentialsRequestMsgBuilder.setUserIdMSB(tenantAdminUserId.getId().getMostSignificantBits()); - userCredentialsRequestMsgBuilder.setUserIdLSB(tenantAdminUserId.getId().getLeastSignificantBits()); - testAutoGeneratedCodeByProtobuf(userCredentialsRequestMsgBuilder); - uplinkMsgBuilder.addUserCredentialsRequestMsg(userCredentialsRequestMsgBuilder.build()); + private UserCredentials buildCredentials(UserCredentialsId userCredentialsUuid, UserId userId, boolean enabled) { + UserCredentials userCredentials = new UserCredentials(); - testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + userCredentials.setId(userCredentialsUuid); + userCredentials.setUserId(userId); + userCredentials.setEnabled(enabled); + userCredentials.setAdditionalInfo(JacksonUtil.newObjectNode()); + + return userCredentials; + } + private User verifyMsgOnCloud(UplinkMsg uplinkMsg, UserId userId, boolean emailExist) throws Exception { edgeImitator.expectResponsesAmount(1); - edgeImitator.expectMessageAmount(1); - edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); + edgeImitator.sendUplinkMsg(uplinkMsg); Assert.assertTrue(edgeImitator.waitForResponses()); - Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg); - UserCredentialsUpdateMsg userCredentialsUpdateMsg = (UserCredentialsUpdateMsg) latestMessage; - UserCredentials userCredentialsMsg = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true); - Assert.assertNotNull(userCredentialsMsg); - Assert.assertEquals(tenantAdminUserId, userCredentialsMsg.getUserId()); + User userFromCloud = doGet("/api/user/" + userId, User.class); + Assert.assertNotNull(userFromCloud); + + if (emailExist) { + Assert.assertNotEquals(DEFAULT_CUSTOMER_USER_EMAIL, userFromCloud.getEmail()); + } else { + Assert.assertEquals(DEFAULT_CUSTOMER_USER_EMAIL, userFromCloud.getEmail()); + } + return userFromCloud; } - @Test - public void sendUserCredentialsRequest() throws Exception { - UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); - UserCredentialsRequestMsg.Builder userCredentialsRequestMsgBuilder = UserCredentialsRequestMsg.newBuilder(); - userCredentialsRequestMsgBuilder.setUserIdMSB(tenantAdminUserId.getId().getMostSignificantBits()); - userCredentialsRequestMsgBuilder.setUserIdLSB(tenantAdminUserId.getId().getLeastSignificantBits()); - testAutoGeneratedCodeByProtobuf(userCredentialsRequestMsgBuilder); - uplinkMsgBuilder.addUserCredentialsRequestMsg(userCredentialsRequestMsgBuilder.build()); + private UplinkMsg constructUserCredentialsUplinkMsg(UserCredentialsId userCredentialsUuid, UserId userId) { + UserCredentials userCredentials = buildCredentials(userCredentialsUuid, userId, true); + userCredentials.setPassword("password"); + UserCredentialsUpdateMsg credsMsg = EdgeMsgConstructorUtils.constructUserCredentialsUpdatedMsg(userCredentials); - testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + return UplinkMsg.newBuilder() + .addUserCredentialsUpdateMsg(credsMsg) + .build(); + } - edgeImitator.expectResponsesAmount(1); - edgeImitator.expectMessageAmount(1); - edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); - Assert.assertTrue(edgeImitator.waitForResponses()); - Assert.assertTrue(edgeImitator.waitForMessages()); + private void assertUserCredentialsFlags(User user, boolean enabled, boolean activated) { + JsonNode info = user.getAdditionalInfo(); + Assert.assertNotNull(info); + Assert.assertEquals(enabled, info.get("userCredentialsEnabled").asBoolean()); + Assert.assertEquals(activated, info.get("userActivated").asBoolean()); + } - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg); - UserCredentialsUpdateMsg userCredentialsUpdateMsg = (UserCredentialsUpdateMsg) latestMessage; - UserCredentials userCredentialsMsg = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true); - Assert.assertNotNull(userCredentialsMsg); - Assert.assertEquals(tenantAdminUserId, userCredentialsMsg.getUserId()); + private UserUpdateMsg getLatestUserUpdateMsg() { + Optional opt = edgeImitator.findMessageByType(UserUpdateMsg.class); + Assert.assertTrue(opt.isPresent()); + return opt.get(); + } - testAutoGeneratedCodeByProtobuf(userCredentialsUpdateMsg); + private UserCredentialsUpdateMsg getLatestUserCredentialsUpdateMsg() { + Optional opt = edgeImitator.findMessageByType(UserCredentialsUpdateMsg.class); + Assert.assertTrue(opt.isPresent()); + return opt.get(); } } diff --git a/application/src/test/java/org/thingsboard/server/edge/WidgetEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/WidgetEdgeTest.java index 999d7aeb62..d6afa81262 100644 --- a/application/src/test/java/org/thingsboard/server/edge/WidgetEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/WidgetEdgeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java index 16d10d9b6e..8b2cef2e8b 100644 --- a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java +++ b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,6 +29,7 @@ import org.thingsboard.edge.rpc.EdgeGrpcClient; import org.thingsboard.edge.rpc.EdgeRpcClient; import org.thingsboard.server.controller.AbstractWebTest; import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; @@ -358,6 +359,11 @@ public class EdgeImitator { result.add(saveDownlinkMsg(calculatedFieldUpdateMsg)); } } + if (downlinkMsg.getAiModelUpdateMsgCount() > 0) { + for (AiModelUpdateMsg aiModelUpdateMsg : downlinkMsg.getAiModelUpdateMsgList()) { + result.add(saveDownlinkMsg(aiModelUpdateMsg)); + } + } if (downlinkMsg.hasEdgeConfiguration()) { result.add(saveDownlinkMsg(downlinkMsg.getEdgeConfiguration())); } @@ -432,7 +438,7 @@ public class EdgeImitator { Optional result; lock.lock(); try { - result = (Optional) downlinkMsgs.stream().filter(downlinkMsg -> downlinkMsg.getClass().isAssignableFrom(tClass)).findAny(); + result = (Optional) downlinkMsgs.stream().filter(downlinkMsg -> tClass.isAssignableFrom(downlinkMsg.getClass())).findAny(); } finally { lock.unlock(); } @@ -444,7 +450,7 @@ public class EdgeImitator { List result; lock.lock(); try { - result = (List) downlinkMsgs.stream().filter(downlinkMsg -> downlinkMsg.getClass().isAssignableFrom(tClass)).collect(Collectors.toList()); + result = (List) downlinkMsgs.stream().filter(downlinkMsg -> tClass.isAssignableFrom(downlinkMsg.getClass())).collect(Collectors.toList()); } finally { lock.unlock(); } diff --git a/application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java b/application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java index e21457f65d..c361172b66 100644 --- a/application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java b/application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java index 5143090e40..14884267f6 100644 --- a/application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/rules/flow/sql/RuleEngineFlowSqlIntegrationTest.java b/application/src/test/java/org/thingsboard/server/rules/flow/sql/RuleEngineFlowSqlIntegrationTest.java index e46760eded..83ec6976a9 100644 --- a/application/src/test/java/org/thingsboard/server/rules/flow/sql/RuleEngineFlowSqlIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/rules/flow/sql/RuleEngineFlowSqlIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java b/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java index 3e29f212f0..f4a913f41d 100644 --- a/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -118,7 +118,7 @@ public abstract class AbstractRuleEngineLifecycleIntegrationTest extends Abstrac .pollInterval(10, MILLISECONDS) .atMost(TIMEOUT, TimeUnit.SECONDS) .until(() -> { - List debugEvents = getEvents(tenantId, ruleChainFinal.getFirstRuleNodeId(), EventType.LC_EVENT.getOldName(), 1000) + List debugEvents = getEvents(tenantId, ruleChainFinal.getFirstRuleNodeId(), EventType.LC_EVENT, 1000) .getData().stream().filter(e -> { var body = e.getBody(); return body.has("event") && body.get("event").asText().equals("STARTED") diff --git a/application/src/test/java/org/thingsboard/server/rules/lifecycle/sql/RuleEngineLifecycleSqlIntegrationTest.java b/application/src/test/java/org/thingsboard/server/rules/lifecycle/sql/RuleEngineLifecycleSqlIntegrationTest.java index f6abcdbef9..3935aecda8 100644 --- a/application/src/test/java/org/thingsboard/server/rules/lifecycle/sql/RuleEngineLifecycleSqlIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/rules/lifecycle/sql/RuleEngineLifecycleSqlIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/apiusage/ApiUsageTest.java b/application/src/test/java/org/thingsboard/server/service/apiusage/ApiUsageTest.java index 961185d3cd..da73600671 100644 --- a/application/src/test/java/org/thingsboard/server/service/apiusage/ApiUsageTest.java +++ b/application/src/test/java/org/thingsboard/server/service/apiusage/ApiUsageTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java b/application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java index 969f6de9f5..a358c0f12c 100644 --- a/application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.id.ApiUsageStateId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.common.msg.queue.TbCallback; @@ -40,6 +41,7 @@ import java.lang.reflect.Field; import java.time.LocalDate; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -47,7 +49,10 @@ import static java.time.ZoneOffset.UTC; import static java.time.temporal.ChronoField.DAY_OF_MONTH; import static java.time.temporal.ChronoUnit.MONTHS; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import static org.junit.Assert.assertEquals; +import static org.thingsboard.server.common.data.ApiFeature.SMS; +import static org.thingsboard.server.common.data.ApiUsageRecordKey.SMS_EXEC_COUNT; @DaoSqlTest public class DefaultTbApiUsageStateServiceTest extends AbstractControllerTest { @@ -157,6 +162,85 @@ public class DefaultTbApiUsageStateServiceTest extends AbstractControllerTest { assertThat(tenantApiUsageState.getNextCycleTs()).isEqualTo(firstOfNextMonth); } + @Test + public void checkTenantCreatedWithSmsDisabledApiUsage() throws Exception { + TenantProfile tenantProfile = new TenantProfile(); + tenantProfile.setName("Test profile"); + TenantProfileData tenantProfileData = new TenantProfileData(); + DefaultTenantProfileConfiguration config = DefaultTenantProfileConfiguration.builder() + .smsEnabled(false) + .build(); + + tenantProfileData.setConfiguration(config); + tenantProfile.setProfileData(tenantProfileData); + tenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); + + Tenant tenant = new Tenant(); + tenant.setTitle("My test tenant"); + tenant.setTenantProfileId(tenantProfile.getId()); + tenant = saveTenant(tenant); + + ApiUsageState apiUsageState = apiUsageStateService.findApiUsageStateByEntityId(tenant.getId()); + assertThat(apiUsageState.getSmsExecState()).isEqualTo(ApiUsageStateValue.DISABLED); + + TenantId finalTenantId = tenant.getId(); + ApiUsageStateId finalApiUsageStateId = apiUsageState.getId(); + + + await().atMost(5, TimeUnit.SECONDS).until(() -> { + Optional smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS.getApiStateKey()).get(); + return smsApiState.isPresent() && smsApiState.get().getValueAsString().equals(ApiUsageStateValue.DISABLED.name()); + }); + + await().atMost(5, TimeUnit.SECONDS).until(() -> { + Optional smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS_EXEC_COUNT.getApiLimitKey()).get(); + return smsApiState.isPresent() && smsApiState.get().getLongValue().get().equals(0L); + }); + + // enable SMS and check that the ApiUsageState is updated accordingly + config = DefaultTenantProfileConfiguration.builder() + .smsEnabled(true) + .maxSms(10) + .build(); + tenantProfileData.setConfiguration(config); + tenantProfile.setProfileData(tenantProfileData); + doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); + + await().atMost(5, TimeUnit.SECONDS).until(() -> + apiUsageStateService.findApiUsageStateByEntityId(finalTenantId).getSmsExecState() == ApiUsageStateValue.ENABLED); + + await().atMost(5, TimeUnit.SECONDS).until(() -> { + Optional smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS.getApiStateKey()).get(); + return smsApiState.isPresent() && smsApiState.get().getValueAsString().equals(ApiUsageStateValue.ENABLED.name()); + }); + + await().atMost(5, TimeUnit.SECONDS).until(() -> { + Optional smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS_EXEC_COUNT.getApiLimitKey()).get(); + return smsApiState.isPresent() && smsApiState.get().getLongValue().get().equals(10L); + }); + + //disable SMS and check that the ApiUsageState is updated accordingly + config = DefaultTenantProfileConfiguration.builder() + .smsEnabled(false) + .build(); + tenantProfileData.setConfiguration(config); + tenantProfile.setProfileData(tenantProfileData); + doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); + + await().atMost(5, TimeUnit.SECONDS).until(() -> + apiUsageStateService.findApiUsageStateByEntityId(finalTenantId).getSmsExecState() == ApiUsageStateValue.DISABLED); + + await().atMost(5, TimeUnit.SECONDS).until(() -> { + Optional smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS.getApiStateKey()).get(); + return smsApiState.isPresent() && smsApiState.get().getValueAsString().equals(ApiUsageStateValue.DISABLED.name()); + }); + + await().atMost(5, TimeUnit.SECONDS).until(() -> { + Optional smsApiState = tsService.findLatest(finalTenantId, finalApiUsageStateId, SMS_EXEC_COUNT.getApiLimitKey()).get(); + return smsApiState.isPresent() && smsApiState.get().getLongValue().get().equals(0L); + }); + } + private TenantProfile createTenantProfile() { TenantProfile tenantProfile = new TenantProfile(); tenantProfile.setName("Tenant Profile"); diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java new file mode 100644 index 0000000000..a20c7b0253 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java @@ -0,0 +1,511 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.TenantProfile; +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.RelationPathQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; +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.common.data.relation.RelationPathLevel; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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 TenantProfile tenantProfile; + @Mock + private TbTenantProfileCache tenantProfileCache; + @Mock + private RelationService relationService; + @InjectMocks + private ActorSystemContext systemContext; + + @BeforeEach + void setUp() { + when(tenantProfileCache.get(any(TenantId.class))).thenReturn(tenantProfile); + when(tenantProfile.getProfileConfiguration()).thenReturn(Optional.of(new DefaultTenantProfileConfiguration())); + ctx = new CalculatedFieldCtx(getCalculatedField(), systemContext); + ctx.init(); + state = new GeofencingCalculatedFieldState(ctx.getEntityId()); + state.setCtx(ctx, null); + state.init(false); + } + + @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.update(newArgs, ctx).isEmpty(); + + 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.update(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry), ctx)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for latitude argument: GEOFENCING. Only SINGLE_VALUE type is allowed."); + } + + @Test + void testUpdateStateWithInvalidArgumentTypeForLongitudeArgument() { + assertThatThrownBy(() -> state.update(Map.of(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry), ctx)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for longitude argument: GEOFENCING. Only SINGLE_VALUE type is allowed."); + } + + @Test + void testUpdateStateWithInvalidArgumentTypeForGeofencingArgument() { + assertThatThrownBy(() -> state.update(Map.of("someArgumentName", latitudeArgEntry), ctx)) + .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.update(newArgs, ctx).isEmpty(); + + 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.update(newArgs, ctx).isEmpty(); + + 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.update(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry), ctx)) + .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.update(Map.of("allowedZones", latitudeArgEntry), ctx)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for geofencing argument entry: SINGLE_VALUE"); + } + + @Test + void testIsReadyWhenNotAllArgPresent() { + assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()).contains(state.getRequiredArguments()); + } + + @Test + void testIsReadyWhenAllArgPresent() { + state.update(Map.of( + ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, + "allowedZones", geofencingAllowedZoneArgEntry, + "restrictedZones", geofencingRestrictedZoneArgEntry + ), ctx); + assertThat(state.isReady()).isTrue(); + assertThat(state.getReadinessStatus().errorMsg()).isNull(); + } + + @Test + void testIsReadyWhenEmptyEntryPresents() { + state.update(Map.of( + ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, + ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, + "allowedZones", geofencingAllowedZoneArgEntry, + "restrictedZones", new GeofencingArgumentEntry(Collections.emptyMap()) + ), ctx); + assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()).contains("restrictedZones"); + } + + @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)); + + TelemetryCalculatedFieldResult result = performCalculation(); + + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(output.getType()); + assertThat(result.getScope()).isEqualTo(output.getScope()); + assertThat(result.getResult().get("values")).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.update(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude), ctx); + + TelemetryCalculatedFieldResult result2 = performCalculation(); + + assertThat(result2).isNotNull(); + assertThat(result2.getType()).isEqualTo(output.getType()); + assertThat(result2.getScope()).isEqualTo(output.getScope()); + assertThat(result2.getResult().get("values")).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, times(2)).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); + List deleteValues = deleteCaptor.getAllValues(); + assertThat(deleteValues).hasSize(2); + + EntityRelation deleteRelationFromFirstIteration = deleteValues.get(0); + assertThat(deleteRelationFromFirstIteration.getFrom()).isEqualTo(ZONE_2_ID); + assertThat(deleteRelationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); + + EntityRelation deleteRelationFromSecondIteration = deleteValues.get(1); + assertThat(deleteRelationFromSecondIteration.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(deleteRelationFromSecondIteration.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)); + + TelemetryCalculatedFieldResult result = performCalculation(); + + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(output.getType()); + assertThat(result.getScope()).isEqualTo(output.getScope()); + assertThat(result.getResult().get("values")).isEmpty(); + + 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.update(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude), ctx); + + TelemetryCalculatedFieldResult result2 = performCalculation(); + + assertThat(result2).isNotNull(); + assertThat(result2.getType()).isEqualTo(output.getType()); + assertThat(result2.getScope()).isEqualTo(output.getScope()); + assertThat(result2.getResult().get("values")).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, times(2)).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); + List deleteValues = deleteCaptor.getAllValues(); + assertThat(deleteValues).hasSize(2); + + EntityRelation deleteRelationFromFirstIteration = deleteValues.get(0); + assertThat(deleteRelationFromFirstIteration.getFrom()).isEqualTo(ZONE_2_ID); + assertThat(deleteRelationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); + + EntityRelation deleteRelationFromSecondIteration = deleteValues.get(1); + assertThat(deleteRelationFromSecondIteration.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(deleteRelationFromSecondIteration.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)); + + TelemetryCalculatedFieldResult result = performCalculation(); + + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(output.getType()); + assertThat(result.getScope()).isEqualTo(output.getScope()); + assertThat(result.getResult().get("values")).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.update(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude), ctx); + + TelemetryCalculatedFieldResult result2 = performCalculation(); + + assertThat(result2).isNotNull(); + assertThat(result2.getType()).isEqualTo(output.getType()); + assertThat(result2.getScope()).isEqualTo(output.getScope()); + assertThat(result2.getResult().get("values")).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, times(2)).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); + List deleteValues = deleteCaptor.getAllValues(); + assertThat(deleteValues).hasSize(2); + + EntityRelation deleteRelationFromFirstIteration = deleteValues.get(0); + assertThat(deleteRelationFromFirstIteration.getFrom()).isEqualTo(ZONE_2_ID); + assertThat(deleteRelationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); + + EntityRelation deleteRelationFromSecondIteration = deleteValues.get(1); + assertThat(deleteRelationFromSecondIteration.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(deleteRelationFromSecondIteration.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 RelationPathQueryDynamicSourceConfiguration(); + allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "AllowedZone"))); + allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration); + allowedZonesGroup.setRelationType("CurrentZone"); + allowedZonesGroup.setDirection(EntitySearchDirection.TO); + + ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("zone", reportStrategy, true); + var restrictedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + restrictedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "RestrictedZone"))); + restrictedZonesGroup.setRefDynamicSourceConfiguration(restrictedZoneDynamicSourceConfiguration); + restrictedZonesGroup.setRelationType("CurrentZone"); + restrictedZonesGroup.setDirection(EntitySearchDirection.TO); + + config.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup)); + + config.setOutput(new TimeSeriesOutput()); + return config; + } + + private TelemetryCalculatedFieldResult performCalculation() throws InterruptedException, ExecutionException { + return (TelemetryCalculatedFieldResult) state.performCalculation(Collections.emptyMap(), ctx).get(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java new file mode 100644 index 0000000000..d7bc39f6e8 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java @@ -0,0 +1,191 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +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; + +@ExtendWith(MockitoExtension.class) +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; + + @Mock + private CalculatedFieldCtx ctx; + + @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(), ctx)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for geofencing argument entry: SINGLE_VALUE"); + } + + @Test + void testUpdateEntryWhenRollingEntryPassed() { + assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L), ctx)) + .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, ctx)).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, ctx)).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, ctx)).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, ctx)).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, ctx)).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, ctx)).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, ctx)).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("Invalid perimeter definition format for Zone with id: " + ZONE_1_ID + ". Failed to parse attribute 'zone'"); + } + + @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("Invalid perimeter definition format for Zone with id: " + ZONE_1_ID + ". Failed to parse attribute 'zone'"); + } + +} 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..40d1ae83b1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java @@ -0,0 +1,169 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 -> INSIDE + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, 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); + // initial eval + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, 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/PropagationArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java new file mode 100644 index 0000000000..7db8bc658a --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java @@ -0,0 +1,257 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfPropagationArg; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.lenient; + +@ExtendWith(MockitoExtension.class) +public class PropagationArgumentEntryTest { + + private final AssetId ENTITY_1_ID = new AssetId(UUID.fromString("b0a8637d-6d67-43d5-a483-c0e391afe805")); + private final AssetId ENTITY_2_ID = new AssetId(UUID.fromString("7bd85073-ded5-414f-a2ef-bd56ad3dbf6a")); + private final AssetId ENTITY_3_ID = new AssetId(UUID.fromString("d64f3e51-2ec2-472f-b475-b095ef8bdc70")); + + private PropagationArgumentEntry entry; + + @Mock + private CalculatedFieldCtx ctx; + + @BeforeEach + void setUp() { + lenient().when(ctx.getMaxRelatedEntitiesPerCfArgument()).thenReturn(1000L); + + List propagationEntityIds = new ArrayList<>(); + propagationEntityIds.add(ENTITY_1_ID); + propagationEntityIds.add(ENTITY_2_ID); + entry = new PropagationArgumentEntry(propagationEntityIds); + } + + @Test + void testArgumentEntryType() { + assertThat(entry.getType()).isEqualTo(ArgumentEntryType.PROPAGATION); + } + + @Test + void testIsEmpty() { + PropagationArgumentEntry emptyEntry = new PropagationArgumentEntry(List.of()); + assertThat(emptyEntry.isEmpty()).isTrue(); + } + + @Test + void testGetValueReturnsPropagationIds() { + assertThat(entry.getValue()).isInstanceOf(Set.class); + @SuppressWarnings("unchecked") + Set value = (Set) entry.getValue(); + assertThat(value).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID); + } + + @Test + void testUpdateEntryWhenSingleEntryPassed() { + assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry(), ctx)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for propagation argument entry: SINGLE_VALUE"); + } + + @Test + void testUpdateEntryWhenRollingEntryPassed() { + assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L), ctx)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for propagation argument entry: TS_ROLLING"); + } + + @Test + void testUpdateEntryReplacesWithNewIds() { + var newIds = new ArrayList(List.of(ENTITY_3_ID, ENTITY_1_ID)); + var updated = new PropagationArgumentEntry(newIds); + + boolean changed = entry.updateEntry(updated, ctx); + + assertThat(changed).isTrue(); + assertThat(entry.getEntityIds()).containsExactlyElementsOf(newIds); + } + + @Test + void testUpdateEntryClearsWhenNewEntryIsEmpty() { + var updatedEmpty = new PropagationArgumentEntry(List.of()); + + boolean changed = entry.updateEntry(updatedEmpty, ctx); + + assertThat(changed).isTrue(); + assertThat(entry.getEntityIds()).isEmpty(); + } + + @Test + void testUpdateEntryWhenAdded() { + var added = new PropagationArgumentEntry(); + added.setAdded(List.of(ENTITY_3_ID)); + + boolean changed = entry.updateEntry(added, ctx); + + assertThat(changed).isTrue(); + assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID, ENTITY_3_ID); + assertThat(entry.getAdded()).isEqualTo(List.of(ENTITY_3_ID)); + } + + @Test + void testUpdateEntryWhenAddedExistingEntity() { + var added = new PropagationArgumentEntry(); + added.setAdded(List.of(ENTITY_2_ID)); + + boolean changed = entry.updateEntry(added, ctx); + + assertThat(changed).isFalse(); + assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID); + assertThat(entry.getAdded()).isNull(); + } + + @Test + void testUpdateEntryWhenRemoved() { + var removed = new PropagationArgumentEntry(); + removed.setRemoved(ENTITY_2_ID); + + boolean changed = entry.updateEntry(removed, ctx); + + assertThat(changed).isTrue(); + assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID); + assertThat(entry.getRemoved()).isNull(); + } + + @Test + void testUpdateEntryWhenRemovedNonExistingEntity() { + var removed = new PropagationArgumentEntry(); + removed.setRemoved(ENTITY_3_ID); + + boolean changed = entry.updateEntry(removed, ctx); + + assertThat(changed).isFalse(); + assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID); + assertThat(entry.getRemoved()).isNull(); + } + + @Test + void testUpdateEntryWhenPartitionStateRestoreAddsMissingIds() { + var restore = new PropagationArgumentEntry(List.of(ENTITY_1_ID, ENTITY_2_ID, ENTITY_3_ID)); + restore.setSyncWithDb(true); + + boolean changed = entry.updateEntry(restore, ctx); + + assertThat(changed).isTrue(); + assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID, ENTITY_3_ID); + assertThat(entry.getAdded()).containsExactly(ENTITY_3_ID); + assertThat(entry.getRemoved()).isNull(); + assertThat(entry.isSyncWithDb()).isFalse(); + } + + @Test + void testUpdateEntryWhenPartitionStateRestoreRemovesStaleIds() { + var restore = new PropagationArgumentEntry(List.of(ENTITY_1_ID)); + restore.setSyncWithDb(true); + + boolean changed = entry.updateEntry(restore, ctx); + + assertThat(changed).isTrue(); // expected to be changed, so we re-check readiness for the state + assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID); + assertThat(entry.getAdded()).isNull(); + assertThat(entry.getRemoved()).isNull(); + assertThat(entry.isSyncWithDb()).isFalse(); + } + + @Test + void testUpdateEntryWhenPartitionStateRestoreAddsAndRemoves() { + var restore = new PropagationArgumentEntry(List.of(ENTITY_1_ID, ENTITY_3_ID)); + restore.setSyncWithDb(true); + + boolean changed = entry.updateEntry(restore, ctx); + + assertThat(changed).isTrue(); + assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_3_ID); + assertThat(entry.getAdded()).containsExactly(ENTITY_3_ID); + assertThat(entry.getRemoved()).isNull(); + assertThat(entry.isSyncWithDb()).isFalse(); + } + + + @Test + void testUpdateEntryWhenPartitionStateRestoreNoChanges() { + var restore = new PropagationArgumentEntry(List.of(ENTITY_1_ID, ENTITY_2_ID)); + restore.setSyncWithDb(true); + + boolean changed = entry.updateEntry(restore, ctx); + + assertThat(changed).isFalse(); + assertThat(entry.getEntityIds()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID); + assertThat(entry.getAdded()).isNull(); + assertThat(entry.getRemoved()).isNull(); + assertThat(entry.isSyncWithDb()).isFalse(); + } + + @Test + void testUpdateEntryWhenPartitionStateRestoreEmptySet() { + var restore = new PropagationArgumentEntry(List.of()); + restore.setSyncWithDb(true); + + boolean changed = entry.updateEntry(restore, ctx); + + assertThat(changed).isTrue(); // expected to be changed, so we re-check readiness for the state + assertThat(entry.getEntityIds()).isEmpty(); + assertThat(entry.getAdded()).isNull(); + assertThat(entry.getRemoved()).isNull(); + assertThat(entry.isSyncWithDb()).isFalse(); + } + + @Test + @SuppressWarnings("unchecked") + void testToTbelCfArgWithValues() { + TbelCfArg arg = entry.toTbelCfArg(); + assertThat(arg).isInstanceOf(TbelCfPropagationArg.class); + + TbelCfPropagationArg tbelCfPropagationArg = (TbelCfPropagationArg) arg; + assertThat(tbelCfPropagationArg.getValue()).isInstanceOf(Set.class); + assertThat((Set) tbelCfPropagationArg.getValue()).containsExactlyInAnyOrder(ENTITY_1_ID, ENTITY_2_ID); + } + + + @Test + @SuppressWarnings("unchecked") + void testToTbelCfArgWithEmptyValues() { + var empty = new PropagationArgumentEntry(List.of()); + TbelCfArg emptyArg = empty.toTbelCfArg(); + assertThat(emptyArg).isInstanceOf(TbelCfPropagationArg.class); + + TbelCfPropagationArg tbelCfPropagationArg = (TbelCfPropagationArg) emptyArg; + assertThat(tbelCfPropagationArg.getValue()).isInstanceOf(Set.class); + assertThat((Set) tbelCfPropagationArg.getValue()).isEmpty(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java new file mode 100644 index 0000000000..66111caf9c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java @@ -0,0 +1,498 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.script.api.tbel.DefaultTbelInvokeService; +import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.TenantProfile; +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.AttributesOutput; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.stats.DefaultStatsFactory; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.service.cf.CalculatedFieldProcessingService; +import org.thingsboard.server.service.cf.PropagationCalculatedFieldResult; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; + +@SpringBootTest(classes = {SimpleMeterRegistry.class, DefaultStatsFactory.class, DefaultTbelInvokeService.class}) +public class PropagationCalculatedFieldStateTest { + + private static final String TEMPERATURE_ARGUMENT_NAME = "t"; + private static final String HUMIDITY_ARGUMENT_NAME = "h"; + private static final String TEST_RESULT_EXPRESSION_KEY = "testResult"; + private static final double TEMPERATURE_VALUE = 12.5; + private static final double HUMIDITY_VALUE = 85; + + private static final PropagationArgumentEntry EMPTY_PROPAGATION_ARGUMENT = new PropagationArgumentEntry(Collections.emptyList()); + + private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("6c3513cb-85e7-4510-8746-1ba01859a8ce")); + private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("be960a50-c029-4698-b2ec-c56a543c561c")); + private final AssetId ASSET_ID_1 = new AssetId(UUID.fromString("d26f0e5b-7d7d-4a61-9f5e-08ab97b30734")); + private final AssetId ASSET_ID_2 = new AssetId(UUID.fromString("1933a317-4df5-4d36-9800-68aded74579b")); + + private final SingleValueArgumentEntry EMPTY_SINGLE_VALUE_ARGUMENT = new SingleValueArgumentEntry(); + + private final SingleValueArgumentEntry temperatureArgumentEntry = + new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("temperature", TEMPERATURE_VALUE), 99L); + + private final SingleValueArgumentEntry humidityArgumentEntry = + new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("humidity", HUMIDITY_VALUE), 99L); + + private final PropagationArgumentEntry propagationArgEntry = + new PropagationArgumentEntry(new ArrayList<>(List.of(ASSET_ID_2, ASSET_ID_1))); + + private PropagationCalculatedFieldState state; + private CalculatedFieldCtx ctx; + + @Autowired + private TbelInvokeService tbelInvokeService; + + @MockitoBean + private TenantProfile tenantProfile; + + @MockitoBean + private TbTenantProfileCache tenantProfileCache; + + @MockitoBean + private ActorSystemContext actorSystemContext; + + @MockitoBean + private CalculatedFieldProcessingService cfProcessingService; + + @BeforeEach + void setUp() { + when(actorSystemContext.getTbelInvokeService()).thenReturn(tbelInvokeService); + when(actorSystemContext.getCalculatedFieldProcessingService()).thenReturn(cfProcessingService); + when(actorSystemContext.getTenantProfileCache()).thenReturn(tenantProfileCache); + when(tenantProfileCache.get(any(TenantId.class))).thenReturn(tenantProfile); + when(tenantProfile.getProfileConfiguration()).thenReturn(Optional.of(new DefaultTenantProfileConfiguration())); + } + + void initCtxAndState(boolean applyExpressionToResolvedArguments) { + ctx = spy(new CalculatedFieldCtx(getCalculatedField(applyExpressionToResolvedArguments), actorSystemContext)); + ctx.init(); + + state = new PropagationCalculatedFieldState(ctx.getEntityId()); + state.setCtx(ctx, null); + state.init(false); + } + + @Test + void testType() { + initCtxAndState(false); + assertThat(state.getType()).isEqualTo(CalculatedFieldType.PROPAGATION); + } + + @Test + void testInitAddsRequiredArgument() { + initCtxAndState(false); + assertThat(state.getRequiredArguments()).containsExactlyInAnyOrder(TEMPERATURE_ARGUMENT_NAME, HUMIDITY_ARGUMENT_NAME, PROPAGATION_CONFIG_ARGUMENT); + } + + @Test + void testIsReadyReturnFalseWhenNoArgumentsSet() { + initCtxAndState(false); + assertThat(state.isReady()).isFalse(); + } + + private static Stream provideInvalidPropagationArgs() { + return Stream.of( + null, + EMPTY_PROPAGATION_ARGUMENT + ); + } + + @ParameterizedTest + @MethodSource("provideInvalidPropagationArgs") + void testIsReadyWhenPropagationArgIsNullOrEmpty(ArgumentEntry propagationEntry) { + initCtxAndState(false); + + Map args = new HashMap<>(); + args.put(TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry); // Valid user arg + args.put(HUMIDITY_ARGUMENT_NAME, humidityArgumentEntry); // Valid user arg + + if (propagationEntry != null) { + args.put(PROPAGATION_CONFIG_ARGUMENT, propagationEntry); + } + state.update(args, ctx); + assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()) + .isEqualTo("No entities found via 'Propagation path to related entities'. Verify the configured relation type and direction."); + } + + @Test + void testIsReadyWithoutExpressionWhenAllArgumentsAreNotEmpty() { + initCtxAndState(false); + Map updatedArgs = Map.of( + TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry, + HUMIDITY_ARGUMENT_NAME, humidityArgumentEntry, + PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry + ); + state.update(updatedArgs, ctx); + assertThat(state.isReady()).isTrue(); + assertThat(state.getReadinessStatus().errorMsg()).isNull(); + } + + + @Test + void testIsReadyWithoutExpressionWhenAtLeastOneArgumentIsNotEmpty() { + initCtxAndState(false); + Map updatedArgs = Map.of( + TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry, + HUMIDITY_ARGUMENT_NAME, EMPTY_SINGLE_VALUE_ARGUMENT, + PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry); + state.update(updatedArgs, ctx); + assertThat(state.isReady()).isTrue(); + assertThat(state.getReadinessStatus().errorMsg()).isNull(); + } + + @Test + void testIsNotReadyWithExpressionWhenAtLeastOneArgumentIsEmpty() { + initCtxAndState(true); + Map updatedArgs = Map.of( + TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry, + HUMIDITY_ARGUMENT_NAME, EMPTY_SINGLE_VALUE_ARGUMENT, + PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry); + state.update(updatedArgs, ctx); + assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()).isEqualTo("Required arguments are missing: h"); + } + + @Test + void testPerformCalculationWithEmptyPropagationArg() throws Exception { + initCtxAndState(false); + Map initArgs = Map.of( + TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry, + HUMIDITY_ARGUMENT_NAME, humidityArgumentEntry, + PROPAGATION_CONFIG_ARGUMENT, EMPTY_PROPAGATION_ARGUMENT); + state.update(initArgs, ctx); + assertThat(state.isReady()).isFalse(); + + // test empty propagation argument calculation + PropagationCalculatedFieldResult result = performCalculation(); + + assertThat(result).isNotNull(); + assertThat(result.isEmpty()).isTrue(); + assertThat(result.getEntityIds()).isNullOrEmpty(); + } + + @Test + void testPerformCalculationWithArgumentsOnlyMode() throws Exception { + initCtxAndState(false); + Map initArgs = Map.of( + TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry, + HUMIDITY_ARGUMENT_NAME, EMPTY_SINGLE_VALUE_ARGUMENT, + PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry); + state.update(initArgs, ctx); + assertThat(state.isReady()).isTrue(); + + PropagationCalculatedFieldResult propagationResult = performCalculation(); + + assertThat(propagationResult).isNotNull(); + assertThat(propagationResult.isEmpty()).isFalse(); + assertThat(propagationResult.getEntityIds()).containsExactly(ASSET_ID_2, ASSET_ID_1); + + TelemetryCalculatedFieldResult result = propagationResult.getResult(); + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(OutputType.ATTRIBUTES); + assertThat(result.getScope()).isEqualTo(AttributeScope.SERVER_SCOPE); + + ObjectNode expectedNode = JacksonUtil.newObjectNode(); + JacksonUtil.addKvEntry(expectedNode, temperatureArgumentEntry.getKvEntryValue(), TEMPERATURE_ARGUMENT_NAME); + + assertThat(result.getResult()).isEqualTo(expectedNode); + } + + @Test + void testPerformCalculationWithExpressionResultMode() throws Exception { + initCtxAndState(true); + Map initArgs = Map.of( + TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry, + HUMIDITY_ARGUMENT_NAME, humidityArgumentEntry, + PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry + ); + state.update(initArgs, ctx); + assertThat(state.isReady()).isTrue(); + PropagationCalculatedFieldResult propagationResult = performCalculation(); + + assertThat(propagationResult).isNotNull(); + assertThat(propagationResult.isEmpty()).isFalse(); + assertThat(propagationResult.getEntityIds()).containsExactly(ASSET_ID_2, ASSET_ID_1); + + TelemetryCalculatedFieldResult result = propagationResult.getResult(); + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(OutputType.ATTRIBUTES); + assertThat(result.getScope()).isEqualTo(AttributeScope.SERVER_SCOPE); + + ObjectNode expectedNode = JacksonUtil.newObjectNode(); + expectedNode.put(TEST_RESULT_EXPRESSION_KEY, (TEMPERATURE_VALUE + HUMIDITY_VALUE) / 2); + + assertThat(result.getResult()).isEqualTo(expectedNode); + } + + @Test + void testPropagationWithUpdatedPropagationArgument() throws ExecutionException, InterruptedException { + initCtxAndState(false); + Map initArgs = Map.of( + TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry, + HUMIDITY_ARGUMENT_NAME, EMPTY_SINGLE_VALUE_ARGUMENT, + PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry + ); + state.update(initArgs, ctx); + assertThat(state.isReady()).isTrue(); + + AssetId newEntityId = new AssetId(UUID.fromString("83e2c962-eeae-4708-984e-e6a24760f9c3")); + PropagationArgumentEntry propagationArgumentEntry = new PropagationArgumentEntry(); + propagationArgumentEntry.setAdded(List.of(newEntityId)); + Map updated = state.update(Map.of(PROPAGATION_CONFIG_ARGUMENT, propagationArgumentEntry), ctx); + assertThat(updated).isNotNull().containsEntry(PROPAGATION_CONFIG_ARGUMENT, propagationArgumentEntry); + + PropagationCalculatedFieldResult propagationCalculatedFieldResult = performCalculation(updated); + assertThat(propagationCalculatedFieldResult).isNotNull(); + assertThat(propagationCalculatedFieldResult.getEntityIds()).isNotNull().containsExactly(newEntityId); + assertThat(propagationCalculatedFieldResult.getResult()).isNotNull(); + assertThat(propagationCalculatedFieldResult.getResult().getResult()).isNotNull(); + assertThat(propagationCalculatedFieldResult.getResult().getResult()).isEqualTo(JacksonUtil.newObjectNode().put(TEMPERATURE_ARGUMENT_NAME, TEMPERATURE_VALUE)); + } + + @Test + void testPropapagationStateInitWithRestoredSetToFalse() { + initCtxAndState(false); + verify(cfProcessingService, never()).fetchPropagationArgumentFromDb(any(), any()); + verify(ctx, never()).scheduleReevaluation(anyLong(), any()); + } + + @Test + void testPropagationStateInitRestoredTrueNoDbChanges_noReevaluation() { + initCtxAndState(false); + + // existing state already matches DB snapshot + Map initArgs = Map.of( + TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry, + HUMIDITY_ARGUMENT_NAME, humidityArgumentEntry, + PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry + ); + state.update(initArgs, ctx); + + // DB returns the same set + when(cfProcessingService.fetchPropagationArgumentFromDb(any(), any())).thenReturn(Optional.of(propagationArgEntry)); + + state.init(true); + + verify(cfProcessingService).fetchPropagationArgumentFromDb(ctx, state.getEntityId()); + verify(ctx, never()).scheduleReevaluation(anyLong(), any()); + } + + @Test + void testPropagationStateInitRestoredTrueOnlyRemovals_noReevaluation() { + initCtxAndState(false); + + Map initArgs = Map.of( + TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry, + HUMIDITY_ARGUMENT_NAME, humidityArgumentEntry, + PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry + ); + state.update(initArgs, ctx); + + // DB snapshot contains only ASSET_ID_1 (ASSET_ID_2 should be removed) + var fromDb = new PropagationArgumentEntry(List.of(ASSET_ID_1)); + when(cfProcessingService.fetchPropagationArgumentFromDb(any(), any())).thenReturn(Optional.of(fromDb)); + + state.init(true); + + verify(cfProcessingService).fetchPropagationArgumentFromDb(ctx, state.getEntityId()); + verify(ctx, never()).scheduleReevaluation(anyLong(), any()); + } + + @Test + void testPropagationStateIsNotReadyInitRestoredTrueAddedEntities_scheduleReevaluation() { + initCtxAndState(false); + Map initArgs = Map.of( + TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry, + HUMIDITY_ARGUMENT_NAME, humidityArgumentEntry, + PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(Collections.emptyList()) + ); + state.update(initArgs, ctx); + assertThat(state.isReady()).isFalse(); + + when(cfProcessingService.fetchPropagationArgumentFromDb(any(), any())).thenReturn(Optional.of(propagationArgEntry)); + + state.init(true); + + verify(cfProcessingService).fetchPropagationArgumentFromDb(ctx, state.getEntityId()); + verify(ctx).scheduleReevaluation(0L, state.getActorCtx()); + } + + @Test + void testPropagationStateInitRestoredTrueAddedEntities_scheduleReevaluation() { + initCtxAndState(false); + + // existing missing ASSET_ID_2 + var existing = new PropagationArgumentEntry(List.of(ASSET_ID_1)); + Map initArgs = Map.of( + TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry, + HUMIDITY_ARGUMENT_NAME, humidityArgumentEntry, + PROPAGATION_CONFIG_ARGUMENT, existing + ); + state.update(initArgs, ctx); + assertThat(state.isReady()).isTrue(); + + // DB snapshot contains both (ASSET_ID_2 should be "added") + var fromDb = new PropagationArgumentEntry(List.of(ASSET_ID_1, ASSET_ID_2)); + when(cfProcessingService.fetchPropagationArgumentFromDb(any(), any())).thenReturn(Optional.of(fromDb)); + + state.init(true); + + verify(cfProcessingService).fetchPropagationArgumentFromDb(ctx, state.getEntityId()); + verify(ctx).scheduleReevaluation(0L, state.getActorCtx()); + } + + @Test + void testPropagationStateInitRestoredTrueDbEmpty_clearsState_noReevaluation() { + initCtxAndState(false); + + // existing has something + Map initArgs = Map.of( + TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry, + HUMIDITY_ARGUMENT_NAME, humidityArgumentEntry, + PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry + ); + state.update(initArgs, ctx); + + // DB snapshot empty -> clear + var fromDb = new PropagationArgumentEntry(Collections.emptyList()); + when(cfProcessingService.fetchPropagationArgumentFromDb(any(), any())).thenReturn(Optional.of(fromDb)); + + state.init(true); + + verify(cfProcessingService).fetchPropagationArgumentFromDb(ctx, state.getEntityId()); + verify(ctx, never()).scheduleReevaluation(anyLong(), any()); + } + + @Test + void testPropagationStateInitRestoredTrueBothEmpty_noReevaluation() { + initCtxAndState(false); + + // Existing propagation argument is empty + Map initArgs = Map.of( + TEMPERATURE_ARGUMENT_NAME, temperatureArgumentEntry, + HUMIDITY_ARGUMENT_NAME, humidityArgumentEntry, + PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(Collections.emptyList()) + ); + state.update(initArgs, ctx); + assertThat(state.isReady()).isFalse(); + + // DB snapshot is also empty + var fromDb = new PropagationArgumentEntry(Collections.emptyList()); + when(cfProcessingService.fetchPropagationArgumentFromDb(any(), any())).thenReturn(Optional.of(fromDb)); + + state.init(true); + + verify(cfProcessingService).fetchPropagationArgumentFromDb(ctx, state.getEntityId()); + verify(ctx, never()).scheduleReevaluation(anyLong(), any()); + } + + private CalculatedField getCalculatedField(boolean applyExpressionToResolvedArguments) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setTenantId(TENANT_ID); + calculatedField.setEntityId(DEVICE_ID); + calculatedField.setType(CalculatedFieldType.PROPAGATION); + calculatedField.setName("Test Propagation CF"); + calculatedField.setConfigurationVersion(1); + calculatedField.setConfiguration(getCalculatedFieldConfig(applyExpressionToResolvedArguments)); + calculatedField.setVersion(1L); + return calculatedField; + } + + private CalculatedFieldConfiguration getCalculatedFieldConfig(boolean applyExpressionToResolvedArguments) { + var config = new PropagationCalculatedFieldConfiguration(); + + config.setRelation(new RelationPathLevel(EntitySearchDirection.TO, EntityRelation.CONTAINS_TYPE)); + config.setApplyExpressionToResolvedArguments(applyExpressionToResolvedArguments); + + Argument temperatureArg = new Argument(); + ReferencedEntityKey tempKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + temperatureArg.setRefEntityKey(tempKey); + + Argument humidityArg = new Argument(); + ReferencedEntityKey humidityKey = new ReferencedEntityKey("humidity", ArgumentType.TS_LATEST, null); + humidityArg.setRefEntityKey(humidityKey); + + config.setArguments(Map.of(TEMPERATURE_ARGUMENT_NAME, temperatureArg, HUMIDITY_ARGUMENT_NAME, humidityArg)); + config.setExpression("return { " + TEST_RESULT_EXPRESSION_KEY + ": (" + TEMPERATURE_ARGUMENT_NAME + " + " + HUMIDITY_ARGUMENT_NAME + ") / 2};"); + + AttributesOutput output = new AttributesOutput(); + output.setScope(AttributeScope.SERVER_SCOPE); + config.setOutput(output); + + return config; + } + + private PropagationCalculatedFieldResult performCalculation() throws ExecutionException, InterruptedException { + return performCalculation(Collections.emptyMap()); + } + + private PropagationCalculatedFieldResult performCalculation(Map updatedArgs) throws ExecutionException, InterruptedException { + return (PropagationCalculatedFieldResult) state.performCalculation(updatedArgs, ctx).get(); + } +} diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesAggregationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesAggregationCalculatedFieldStateTest.java new file mode 100644 index 0000000000..cbde708ff8 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesAggregationCalculatedFieldStateTest.java @@ -0,0 +1,242 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.cf.ctx.state; + +import com.fasterxml.jackson.databind.JsonNode; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.thingsboard.script.api.tbel.DefaultTbelInvokeService; +import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.TenantProfile; +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.OutputType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +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.BooleanDataEntry; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.stats.DefaultStatsFactory; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; +import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@SpringBootTest(classes = {SimpleMeterRegistry.class, DefaultStatsFactory.class, DefaultTbelInvokeService.class}) +public class RelatedEntitiesAggregationCalculatedFieldStateTest { + + private final long ts = System.currentTimeMillis(); + + private final TenantId tenantId = TenantId.fromUUID(UUID.fromString("cceba360-71e9-44ab-8596-c600d60ee0d0")); + private final AssetProfileId assetProfileId = new AssetProfileId(UUID.fromString("ccced83b-e5f6-4978-bba0-c2df46de9a35")); + private final AssetId assetId = new AssetId(UUID.fromString("982dfdee-b2bc-4f04-a49d-7ffd70940c69")); + private final DeviceId device1 = new DeviceId(UUID.fromString("47f1fef5-a3b7-46e7-9732-b7669d3ef885")); + private final DeviceId device2 = new DeviceId(UUID.fromString("3b097e5e-9eef-46f4-8997-937075e6f342")); + + private RelatedEntitiesAggregationCalculatedFieldState state; + private CalculatedFieldCtx ctx; + + @Autowired + private TbelInvokeService tbelInvokeService; + + @MockitoBean + private TenantProfile tenantProfile; + + @MockitoBean + private TbTenantProfileCache tenantProfileCache; + + @MockitoBean + private ActorSystemContext actorSystemContext; + + @BeforeEach + void setUp() { + when(actorSystemContext.getTbelInvokeService()).thenReturn(tbelInvokeService); + when(actorSystemContext.getTenantProfileCache()).thenReturn(tenantProfileCache); + when(tenantProfileCache.get(any(TenantId.class))).thenReturn(tenantProfile); + when(tenantProfile.getProfileConfiguration()).thenReturn(Optional.of(new DefaultTenantProfileConfiguration())); + + initCtxAndState(); + } + + void initCtxAndState() { + ctx = new CalculatedFieldCtx(getCalculatedField(), actorSystemContext); + ctx.init(); + + state = new RelatedEntitiesAggregationCalculatedFieldState(assetId); + state.setCtx(ctx, null); + state.init(false); + } + + @Test + void testType() { + assertThat(state.getType()).isEqualTo(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + } + + @Test + void testInitAddsRequiredArgument() { + assertThat(state.getRequiredArguments()).contains("oc"); + } + + @Test + void testIsReadyWhenNoRelatedEntities() { + assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()) + .isEqualTo("No entities found via 'Aggregation path to related entities'. Verify the configured relation type and direction."); + } + + @Test + void testUpdateArguments() { + assertThat(state.getLastArgsRefreshTs()).isEqualTo(-1); + + state.update(arguments(), ctx); + + assertThat(state.getLastArgsRefreshTs()).isGreaterThan(-1); + } + + @Test + void testPerformCalculationWhenArgsUpdatedButIntervalDidNotPass() throws Exception { + // deduplication interval 60 sec + state.setLastMetricsEvalTs(ts - TimeUnit.SECONDS.toMillis(10)); + state.setLastArgsRefreshTs(ts - TimeUnit.SECONDS.toMillis(5)); + + assertThat(state.performCalculation(arguments(), ctx).get()).isEqualTo(TelemetryCalculatedFieldResult.EMPTY); + } + + @Test + void testPerformCalculationWhenIntervalPassedButNoArgsUpdated() throws Exception { + state.setLastMetricsEvalTs(ts - TimeUnit.SECONDS.toMillis(90)); + state.setLastArgsRefreshTs(ts - TimeUnit.SECONDS.toMillis(90)); + + assertThat(state.performCalculation(arguments(), ctx).get()).isEqualTo(TelemetryCalculatedFieldResult.EMPTY); + } + + @Test + void testPerformCalculationWhenIntervalPassedAndArgsUpdated() throws Exception { + state.setLastMetricsEvalTs(ts - TimeUnit.SECONDS.toMillis(70)); + state.setLastArgsRefreshTs(ts - TimeUnit.SECONDS.toMillis(10)); + + Map arguments = arguments(); + state.update(arguments, ctx); + + TelemetryCalculatedFieldResult calculatedFieldResult = (TelemetryCalculatedFieldResult) state.performCalculation(arguments, ctx).get(); + assertThat(calculatedFieldResult).isNotNull(); + assertThat(calculatedFieldResult.isEmpty()).isFalse(); + assertThat(calculatedFieldResult.getType()).isEqualTo(OutputType.TIME_SERIES); + + JsonNode result = calculatedFieldResult.getResult(); + assertThat(result).isNotNull(); + safeAssert(result.get("ts"), String.valueOf(state.getLatestTimestamp())); + JsonNode values = result.get("values"); + safeAssert(values.get("freeSpaces"), "1"); + safeAssert(values.get("occupiedSpaces"), "1"); + safeAssert(values.get("totalSpaces"), "2"); + } + + private void safeAssert(JsonNode node, String expected) { + assertThat(node).isNotNull(); + assertThat(node.asText()).isEqualTo(expected); + } + + private Map arguments() { + Map arguments = new HashMap<>(); + Map entityArguments = new HashMap<>(); + entityArguments.put(device1, new SingleValueArgumentEntry(ts - 100, new BooleanDataEntry("occupied", true), 23L)); + entityArguments.put(device2, new SingleValueArgumentEntry(ts - 80, new BooleanDataEntry("occupied", false), 26L)); + RelatedEntitiesArgumentEntry entry = new RelatedEntitiesArgumentEntry(entityArguments, false); + arguments.put("oc", entry); + return arguments; + } + + private CalculatedField getCalculatedField() { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setTenantId(tenantId); + calculatedField.setEntityId(assetProfileId); + calculatedField.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + calculatedField.setName("Test CF"); + calculatedField.setConfigurationVersion(1); + + var config = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); + + config.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE)); + + Argument oc = new Argument(); + ReferencedEntityKey refKey = new ReferencedEntityKey("occupied", ArgumentType.TS_LATEST, null); + oc.setRefEntityKey(refKey); + config.setArguments(Map.of("oc", oc)); + + Map aggMetrics = new HashMap<>(); + + AggMetric freeSpaces = new AggMetric(); + freeSpaces.setFunction(AggFunction.COUNT); + freeSpaces.setFilter("return oc == false;"); + freeSpaces.setInput(new AggKeyInput("oc")); + aggMetrics.put("freeSpaces", freeSpaces); + + AggMetric occupiedSpaces = new AggMetric(); + occupiedSpaces.setFunction(AggFunction.COUNT); + occupiedSpaces.setFilter("return oc == true;"); + occupiedSpaces.setInput(new AggKeyInput("oc")); + aggMetrics.put("occupiedSpaces", occupiedSpaces); + + AggMetric totalSpaces = new AggMetric(); + totalSpaces.setFunction(AggFunction.COUNT); + totalSpaces.setInput(new AggFunctionInput("return 1;")); + aggMetrics.put("totalSpaces", totalSpaces); + config.setMetrics(aggMetrics); + + config.setDeduplicationIntervalInSec(60); + + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setDecimalsByDefault(0); + config.setOutput(output); + + config.setUseLatestTs(true); + + calculatedField.setConfiguration(config); + calculatedField.setVersion(1L); + return calculatedField; + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesArgumentEntryTest.java new file mode 100644 index 0000000000..725860dd80 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesArgumentEntryTest.java @@ -0,0 +1,110 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.lenient; + +@ExtendWith(MockitoExtension.class) +public class RelatedEntitiesArgumentEntryTest { + + private RelatedEntitiesArgumentEntry entry; + + @Mock + private CalculatedFieldCtx ctx; + + private final DeviceId device1 = new DeviceId(UUID.fromString("1984e5f4-9ff0-4187-84ae-e4438bba4c8a")); + private final DeviceId device2 = new DeviceId(UUID.fromString("937fc062-1a9d-438f-aa22-55a93fc908b7")); + + private final long ts = System.currentTimeMillis(); + + @BeforeEach + void setUp() { + lenient().when(ctx.getMaxRelatedEntitiesPerCfArgument()).thenReturn(1000L); + + Map aggInputs = new HashMap<>(); + aggInputs.put(device1, new SingleValueArgumentEntry(device1, new BasicTsKvEntry(ts - 100, new LongDataEntry("key", 12L), 1L))); + aggInputs.put(device2, new SingleValueArgumentEntry(device2, new BasicTsKvEntry(ts - 150, new LongDataEntry("key", 16L), 6L))); + + entry = new RelatedEntitiesArgumentEntry(aggInputs, false); + } + + @Test + void testUpdateEntryWhenNotAggEntryPassed() { + assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L), ctx)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for aggregation argument entry: " + ArgumentEntryType.TS_ROLLING); + } + + @Test + void testUpdateEntryWhenAggArgumentEntryPasser() { + DeviceId device3 = new DeviceId(UUID.randomUUID()); + DeviceId device4 = new DeviceId(UUID.randomUUID()); + + RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry = new RelatedEntitiesArgumentEntry(Map.of( + device3, new SingleValueArgumentEntry(device3, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 16L), 13L)), + device4, new SingleValueArgumentEntry(device4, new BasicTsKvEntry(ts - 60, new LongDataEntry("key", 23L), 7L)) + ), false); + + assertThat(entry.updateEntry(relatedEntitiesArgumentEntry, ctx)).isTrue(); + + Map aggInputs = entry.getEntityInputs(); + assertThat(aggInputs.size()).isEqualTo(4); + assertThat(aggInputs.get(device3)).isEqualTo(relatedEntitiesArgumentEntry.getEntityInputs().get(device3)); + assertThat(aggInputs.get(device4)).isEqualTo(relatedEntitiesArgumentEntry.getEntityInputs().get(device4)); + } + + @Test + void testUpdateEntryWhenSingleValueArgumentEntryPassedAndNoEntriesById() { + DeviceId device3 = new DeviceId(UUID.randomUUID()); + + SingleValueArgumentEntry singleEntityArgumentEntry = new SingleValueArgumentEntry(device3, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 18L), 10L)); + + assertThat(entry.updateEntry(singleEntityArgumentEntry, ctx)).isTrue(); + + Map aggInputs = entry.getEntityInputs(); + assertThat(aggInputs.size()).isEqualTo(3); + assertThat(aggInputs.get(device3)).isEqualTo(singleEntityArgumentEntry); + } + + @Test + void testUpdateEntryWhenSingleValueArgumentEntryPassedAndEntryByIdExist() { + SingleValueArgumentEntry singleEntityArgumentEntry = new SingleValueArgumentEntry(device2, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 18L), 10L)); + + assertThat(entry.updateEntry(singleEntityArgumentEntry, ctx)).isTrue(); + + Map aggInputs = entry.getEntityInputs(); + assertThat(aggInputs.size()).isEqualTo(2); + assertThat(aggInputs.get(device2)).isEqualTo(singleEntityArgumentEntry); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java index 8ed42c43e8..e1b881fec1 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 @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,20 +18,23 @@ package org.thingsboard.server.service.cf.ctx.state; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.tbel.DefaultTbelInvokeService; import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.TenantProfile; 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.AttributesOutput; 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.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.AssetId; @@ -39,12 +42,15 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.stats.DefaultStatsFactory; -import org.thingsboard.server.dao.usagerecord.ApiLimitService; -import org.thingsboard.server.service.cf.CalculatedFieldResult; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; +import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.TreeMap; import java.util.UUID; import java.util.concurrent.ExecutionException; @@ -72,14 +78,24 @@ public class ScriptCalculatedFieldStateTest { private TbelInvokeService tbelInvokeService; @MockitoBean - private ApiLimitService apiLimitService; + private TenantProfile tenantProfile; + + @MockitoBean + private TbTenantProfileCache tenantProfileCache; @BeforeEach void setUp() { - when(apiLimitService.getLimit(any(), any())).thenReturn(1000L); - ctx = new CalculatedFieldCtx(getCalculatedField(), tbelInvokeService, apiLimitService); + ActorSystemContext systemContext = Mockito.mock(ActorSystemContext.class); + when(systemContext.getTbelInvokeService()).thenReturn(tbelInvokeService); + when(systemContext.getTenantProfileCache()).thenReturn(tenantProfileCache); + when(tenantProfileCache.get(any(TenantId.class))).thenReturn(tenantProfile); + when(tenantProfile.getProfileConfiguration()).thenReturn(Optional.of(new DefaultTenantProfileConfiguration())); + + ctx = new CalculatedFieldCtx(getCalculatedField(), systemContext); ctx.init(); - state = new ScriptCalculatedFieldState(ctx.getArgNames()); + state = new ScriptCalculatedFieldState(ctx.getEntityId()); + state.setCtx(ctx, null); + state.init(false); } @Test @@ -92,7 +108,7 @@ public class ScriptCalculatedFieldStateTest { state.arguments = new HashMap<>(Map.of("assetHumidity", assetHumidityArgEntry)); Map newArgs = Map.of("deviceTemperature", deviceTemperatureArgEntry); - boolean stateUpdated = state.updateState(ctx, newArgs); + boolean stateUpdated = !state.update(newArgs, ctx).isEmpty(); assertThat(stateUpdated).isTrue(); assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf( @@ -109,7 +125,7 @@ public class ScriptCalculatedFieldStateTest { SingleValueArgumentEntry newArgEntry = new SingleValueArgumentEntry(ts, new LongDataEntry("assetHumidity", 41L), 349L); Map newArgs = Map.of("assetHumidity", newArgEntry); - boolean stateUpdated = state.updateState(ctx, newArgs); + boolean stateUpdated = !state.update(newArgs, ctx).isEmpty(); assertThat(stateUpdated).isTrue(); assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf( @@ -124,7 +140,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(); + TelemetryCalculatedFieldResult result = performCalculation(); assertThat(result).isNotNull(); Output output = getCalculatedFieldConfig().getOutput(); @@ -140,7 +156,7 @@ public class ScriptCalculatedFieldStateTest { "assetHumidity", new SingleValueArgumentEntry(System.currentTimeMillis() - 10, new LongDataEntry("a", 45L), 10L) )); - CalculatedFieldResult result = state.performCalculation(ctx).get(); + TelemetryCalculatedFieldResult result = performCalculation(); assertThat(result).isNotNull(); Output output = getCalculatedFieldConfig().getOutput(); @@ -152,20 +168,21 @@ public class ScriptCalculatedFieldStateTest { @Test void testIsReadyWhenNotAllArgPresent() { assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()).contains(state.getRequiredArguments()); } @Test void testIsReadyWhenAllArgPresent() { - state.arguments = new HashMap<>(Map.of("deviceTemperature", deviceTemperatureArgEntry, "assetHumidity", assetHumidityArgEntry)); - + state.update(Map.of("deviceTemperature", deviceTemperatureArgEntry, "assetHumidity", assetHumidityArgEntry), ctx); assertThat(state.isReady()).isTrue(); + assertThat(state.getReadinessStatus().errorMsg()).isNull(); } @Test void testIsReadyWhenEmptyEntryPresents() { - state.arguments = new HashMap<>(Map.of("deviceTemperature", new TsRollingArgumentEntry(5, 30000L), "assetHumidity", assetHumidityArgEntry)); - + state.update(Map.of("deviceTemperature", new TsRollingArgumentEntry(5, 30000L), "assetHumidity", assetHumidityArgEntry), ctx); assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()).contains("deviceTemperature"); } private TsRollingArgumentEntry createRollingArgEntry() { @@ -211,8 +228,7 @@ public class ScriptCalculatedFieldStateTest { config.setExpression("return {\"maxDeviceTemperature\": deviceTemperature.max(), \"assetHumidity\": assetHumidity / 2 }"); - Output output = new Output(); - output.setType(OutputType.ATTRIBUTES); + AttributesOutput output = new AttributesOutput(); output.setScope(AttributeScope.SERVER_SCOPE); config.setOutput(output); @@ -220,4 +236,8 @@ public class ScriptCalculatedFieldStateTest { return config; } + private TelemetryCalculatedFieldResult performCalculation() throws InterruptedException, ExecutionException { + return (TelemetryCalculatedFieldResult) state.performCalculation(Collections.emptyMap(), ctx).get(); + } + } \ No newline at end of file diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java index c1616a85db..4454d41e6e 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 @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,17 +18,20 @@ package org.thingsboard.server.service.cf.ctx.state; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.TenantProfile; 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.AttributesOutput; 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.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.AssetId; @@ -38,11 +41,14 @@ import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; -import org.thingsboard.server.dao.usagerecord.ApiLimitService; -import org.thingsboard.server.service.cf.CalculatedFieldResult; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; +import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.ExecutionException; @@ -66,14 +72,21 @@ public class SimpleCalculatedFieldStateTest { private CalculatedFieldCtx ctx; @Mock - private ApiLimitService apiLimitService; + private TenantProfile tenantProfile; + @Mock + private TbTenantProfileCache tenantProfileCache; + @InjectMocks + private ActorSystemContext systemContext; @BeforeEach void setUp() { - when(apiLimitService.getLimit(any(), any())).thenReturn(1000L); - ctx = new CalculatedFieldCtx(getCalculatedField(), null, apiLimitService); + when(tenantProfileCache.get(any(TenantId.class))).thenReturn(tenantProfile); + when(tenantProfile.getProfileConfiguration()).thenReturn(Optional.of(new DefaultTenantProfileConfiguration())); + ctx = new CalculatedFieldCtx(getCalculatedField(), systemContext); ctx.init(); - state = new SimpleCalculatedFieldState(ctx.getArgNames()); + state = new SimpleCalculatedFieldState(ctx.getEntityId()); + state.setCtx(ctx, null); + state.init(false); } @Test @@ -89,7 +102,7 @@ public class SimpleCalculatedFieldStateTest { )); Map newArgs = Map.of("key3", key3ArgEntry); - boolean stateUpdated = state.updateState(ctx, newArgs); + boolean stateUpdated = !state.update(newArgs, ctx).isEmpty(); assertThat(stateUpdated).isTrue(); assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf( @@ -107,7 +120,7 @@ public class SimpleCalculatedFieldStateTest { SingleValueArgumentEntry newArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis(), new LongDataEntry("key1", 18L), 190L); Map newArgs = Map.of("key1", newArgEntry); - boolean stateUpdated = state.updateState(ctx, newArgs); + boolean stateUpdated = !state.update(newArgs, ctx).isEmpty(); assertThat(stateUpdated).isTrue(); assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf(Map.of("key1", newArgEntry)); @@ -121,9 +134,10 @@ public class SimpleCalculatedFieldStateTest { )); Map newArgs = Map.of("key3", new TsRollingArgumentEntry(10, 30000L)); - assertThatThrownBy(() -> state.updateState(ctx, newArgs)) + assertThatThrownBy(() -> state.update(newArgs, ctx)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Rolling argument entry is not supported for simple calculated fields."); + .hasMessage("Unsupported argument type detected for argument: key3. " + + "Rolling argument entry is not supported for simple calculated fields."); } @Test @@ -134,7 +148,7 @@ public class SimpleCalculatedFieldStateTest { "key3", key3ArgEntry )); - CalculatedFieldResult result = state.performCalculation(ctx).get(); + TelemetryCalculatedFieldResult result = performCalculation(); assertThat(result).isNotNull(); Output output = getCalculatedFieldConfig().getOutput(); @@ -151,7 +165,7 @@ public class SimpleCalculatedFieldStateTest { "key3", key3ArgEntry )); - assertThatThrownBy(() -> state.performCalculation(ctx)) + assertThatThrownBy(() -> state.performCalculation(Collections.emptyMap(), ctx)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Argument 'key2' is not a number."); } @@ -164,7 +178,7 @@ public class SimpleCalculatedFieldStateTest { "key3", key3ArgEntry )); - CalculatedFieldResult result = state.performCalculation(ctx).get(); + TelemetryCalculatedFieldResult result = performCalculation(); assertThat(result).isNotNull(); Output output = getCalculatedFieldConfig().getOutput(); @@ -185,7 +199,7 @@ public class SimpleCalculatedFieldStateTest { output.setDecimalsByDefault(3); ctx.setOutput(output); - CalculatedFieldResult result = state.performCalculation(ctx).get(); + TelemetryCalculatedFieldResult result = performCalculation(); assertThat(result).isNotNull(); assertThat(result.getType()).isEqualTo(output.getType()); @@ -196,28 +210,29 @@ public class SimpleCalculatedFieldStateTest { @Test void testIsReadyWhenNotAllArgPresent() { assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()).contains(state.getRequiredArguments()); } @Test void testIsReadyWhenAllArgPresent() { - state.arguments = new HashMap<>(Map.of( + state.update(Map.of( "key1", key1ArgEntry, "key2", key2ArgEntry, "key3", key3ArgEntry - )); - + ), ctx); assertThat(state.isReady()).isTrue(); + assertThat(state.getReadinessStatus().errorMsg()).isNull(); } @Test void testIsReadyWhenEmptyEntryPresents() { - state.arguments = new HashMap<>(Map.of( + state.update(Map.of( "key1", key1ArgEntry, - "key2", key2ArgEntry - )); - state.getArguments().put("key3", new SingleValueArgumentEntry()); - + "key2", key2ArgEntry, + "key3", new SingleValueArgumentEntry() + ), ctx); assertThat(state.isReady()).isFalse(); + assertThat(state.getReadinessStatus().errorMsg()).contains("key3"); } private CalculatedField getCalculatedField() { @@ -254,9 +269,8 @@ public class SimpleCalculatedFieldStateTest { config.setExpression("key1 + key2 + key3"); - Output output = new Output(); + AttributesOutput output = new AttributesOutput(); output.setName("output"); - output.setType(OutputType.ATTRIBUTES); output.setScope(AttributeScope.SERVER_SCOPE); output.setDecimalsByDefault(0); @@ -265,4 +279,8 @@ public class SimpleCalculatedFieldStateTest { return config; } + private TelemetryCalculatedFieldResult performCalculation() throws InterruptedException, ExecutionException { + return (TelemetryCalculatedFieldResult) state.performCalculation(Collections.emptyMap(), ctx).get(); + } + } \ No newline at end of file diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java index 5ea3808468..57bb161688 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntryTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,9 @@ package org.thingsboard.server.service.cf.ctx.state; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.script.api.tbel.TbelCfArg; import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; import org.thingsboard.server.common.data.kv.JsonDataEntry; @@ -29,10 +32,14 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@ExtendWith(MockitoExtension.class) public class SingleValueArgumentEntryTest { private SingleValueArgumentEntry entry; + @Mock + private CalculatedFieldCtx ctx; + private final long ts = System.currentTimeMillis(); @BeforeEach @@ -47,43 +54,48 @@ public class SingleValueArgumentEntryTest { @Test void testUpdateEntryWhenRollingEntryPassed() { - assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L))) + assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L), ctx)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Unsupported argument entry type for single value argument entry: " + ArgumentEntryType.TS_ROLLING); } @Test void testUpdateEntryWithTheSameTs() { - assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts, new LongDataEntry("key", 13L), 363L))).isFalse(); + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts, new LongDataEntry("key", 13L), 363L), ctx)).isFalse(); + } + + @Test + void testUpdateEntryWithTheSameTsAndDifferentVersion() { + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts, new LongDataEntry("key", 13L), 364L), ctx)).isTrue(); } @Test void testUpdateEntryWhenNewVersionIsNull() { - assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 16, new LongDataEntry("key", 13L), null))).isTrue(); + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 16, new LongDataEntry("key", 13L), null), ctx)).isTrue(); assertThat(entry.getValue()).isEqualTo(13L); assertThat(entry.getVersion()).isNull(); } @Test void testUpdateEntryWhenNewVersionIsGreaterThanCurrent() { - assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 18L), 369L))).isTrue(); + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 18L), 369L), ctx)).isTrue(); assertThat(entry.getValue()).isEqualTo(18L); assertThat(entry.getVersion()).isEqualTo(369L); } @Test void testUpdateEntryWhenNewVersionIsLessThanCurrent() { - assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 18L), 234L))).isFalse(); + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 18L), 234L), ctx)).isFalse(); } @Test void testUpdateEntryWhenValueWasNotChanged() { - assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 11L), 364L))).isTrue(); + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts + 18, new LongDataEntry("key", 11L), 364L), ctx)).isTrue(); } @Test void testUpdateEntryWithOldTs() { - assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts - 10, new LongDataEntry("key", 14L), 365L))).isFalse(); + assertThat(entry.updateEntry(new SingleValueArgumentEntry(ts - 10, new LongDataEntry("key", 14L), 365L), ctx)).isFalse(); } @Test @@ -115,4 +127,5 @@ public class SingleValueArgumentEntryTest { expectedList.add(Map.of("test2", 20)); assertThat(singleValueArg.getValue()).isEqualTo(expectedList); } + } diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntryTest.java index b1f8063857..7dea28e655 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntryTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,9 @@ package org.thingsboard.server.service.cf.ctx.state; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; @@ -26,10 +29,14 @@ import java.util.TreeMap; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@ExtendWith(MockitoExtension.class) public class TsRollingArgumentEntryTest { private TsRollingArgumentEntry entry; + @Mock + private CalculatedFieldCtx ctx; + private final long ts = System.currentTimeMillis(); @BeforeEach @@ -51,7 +58,7 @@ public class TsRollingArgumentEntryTest { void testUpdateEntryWhenSingleValueEntryPassed() { SingleValueArgumentEntry newEntry = new SingleValueArgumentEntry(ts - 10, new DoubleDataEntry("key", 23.0), 123L); - assertThat(entry.updateEntry(newEntry)).isTrue(); + assertThat(entry.updateEntry(newEntry, ctx)).isTrue(); assertThat(entry.getTsRecords()).hasSize(4); assertThat(entry.getTsRecords().get(ts - 10)).isEqualTo(23.0); } @@ -64,7 +71,7 @@ public class TsRollingArgumentEntryTest { values.put(ts - 5, 1.0); newEntry.setTsRecords(values); - assertThat(entry.updateEntry(newEntry)).isTrue(); + assertThat(entry.updateEntry(newEntry, ctx)).isTrue(); assertThat(entry.getTsRecords()).hasSize(5); assertThat(entry.getTsRecords()).isEqualTo(Map.of( ts - 40, 10.0, @@ -79,7 +86,7 @@ public class TsRollingArgumentEntryTest { void testUpdateEntryWhenValueIsNotNumber() { SingleValueArgumentEntry newEntry = new SingleValueArgumentEntry(ts - 10, new StringDataEntry("key", "string"), 123L); - assertThat(entry.updateEntry(newEntry)).isTrue(); + assertThat(entry.updateEntry(newEntry, ctx)).isTrue(); assertThat(entry.getTsRecords().get(ts - 10)).isNaN(); } @@ -93,7 +100,7 @@ public class TsRollingArgumentEntryTest { newEntry.setTsRecords(values); entry = new TsRollingArgumentEntry(3, 30000L); - assertThat(entry.updateEntry(newEntry)).isTrue(); + assertThat(entry.updateEntry(newEntry, ctx)).isTrue(); assertThat(entry.getTsRecords()).hasSize(1); assertThat(entry.getTsRecords()).isEqualTo(Map.of( ts - 5, 0.0 @@ -111,7 +118,7 @@ public class TsRollingArgumentEntryTest { newEntry.setTsRecords(values); entry = new TsRollingArgumentEntry(3, 30000L); - assertThat(entry.updateEntry(newEntry)).isTrue(); + assertThat(entry.updateEntry(newEntry, ctx)).isTrue(); assertThat(entry.getTsRecords()).hasSize(3); assertThat(entry.getTsRecords()).isEqualTo(Map.of( ts - 18, 0.0, diff --git a/application/src/test/java/org/thingsboard/server/service/device/provision/DeviceProvisionServiceTest.java b/application/src/test/java/org/thingsboard/server/service/device/provision/DeviceProvisionServiceTest.java index 02da289be4..de86e46cd0 100644 --- a/application/src/test/java/org/thingsboard/server/service/device/provision/DeviceProvisionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/device/provision/DeviceProvisionServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java b/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java index 1ee6e31372..4bbb51068c 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,10 +15,12 @@ */ package org.thingsboard.server.service.edge; +import com.fasterxml.jackson.databind.JsonNode; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -36,6 +38,10 @@ import org.thingsboard.rule.engine.rest.TbSendRestApiCallReplyNode; import org.thingsboard.rule.engine.telemetry.TbCalculatedFieldsNode; import org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode; import org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.gen.edge.v1.EdgeVersion; @@ -44,6 +50,7 @@ import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import java.lang.reflect.Constructor; import java.util.List; import java.util.Optional; +import java.util.UUID; import java.util.stream.Stream; import static org.thingsboard.server.service.edge.EdgeMsgConstructorUtils.EXCLUDED_NODES_BY_EDGE_VERSION; @@ -155,4 +162,120 @@ public class EdgeMsgConstructorUtilsTest { String.format("For EdgeVersion '%s', ruleNode '%s' should not be included.", edgeVersion, ruleNode.getType())); } + @Test + @DisplayName("mergeDownlinkDuplicates: latest per attribute key is retained and duplicates removed") + public void testMergeDownlinkDuplicates() { + UUID deviceId = UUID.randomUUID(); + UUID assetId = UUID.randomUUID(); + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + + var deviceAttrUpdate1 = createEdgeEvent(tenantId, 1, EdgeEventActionType.ATTRIBUTES_UPDATED, + deviceId, EdgeEventType.DEVICE, createAttrBody(1_000L, "{\"a\":1,\"b\":1,\"d\":1}")); + var deviceAttrUpdate2 = createEdgeEvent(tenantId, 2, EdgeEventActionType.ATTRIBUTES_UPDATED, + deviceId, EdgeEventType.DEVICE, createAttrBody(2_000L, "{\"a\":2,\"b\":2,\"c\":2}")); + var deviceAttrUpdate3 = createEdgeEvent(tenantId, 3, EdgeEventActionType.ATTRIBUTES_UPDATED, + deviceId, EdgeEventType.DEVICE, createAttrBody(3_000L, "{\"a\":3,\"d\":3}")); + + var deviceUpdate = createEdgeEvent(tenantId, 4, EdgeEventActionType.UPDATED, + deviceId, EdgeEventType.DEVICE, null); + var deviceUpdateDup = createEdgeEvent(tenantId, 5, EdgeEventActionType.UPDATED, + deviceId, EdgeEventType.DEVICE, null); + + var assetAttrUpdate1 = createEdgeEvent(tenantId, 6, EdgeEventActionType.ATTRIBUTES_UPDATED, + assetId, EdgeEventType.ASSET, createAttrBody(6_000L, "{\"a\":6,\"d\":6}")); + var assetAttrUpdate2 = createEdgeEvent(tenantId, 7, EdgeEventActionType.ATTRIBUTES_UPDATED, + assetId, EdgeEventType.ASSET, createAttrBody(7_000L, "{\"a\":7,\"b\":7,\"c\":7}")); + var assetAttrUpdate3 = createEdgeEvent(tenantId, 8, EdgeEventActionType.ATTRIBUTES_UPDATED, + assetId, EdgeEventType.ASSET, createAttrBody(8_000L, "{\"a\":8,\"d\":8}")); + + List input = List.of(deviceAttrUpdate1, deviceAttrUpdate2, deviceAttrUpdate3, + deviceUpdate, deviceUpdateDup, + assetAttrUpdate1, assetAttrUpdate2, assetAttrUpdate3); + List merged = EdgeMsgConstructorUtils.mergeAndFilterDownlinkDuplicates(input); + + Assertions.assertEquals(5, merged.size()); + + EdgeEvent deviceMergedAttrBC = merged.get(0); + Assertions.assertEquals(2, deviceMergedAttrBC.getSeqId()); + Assertions.assertEquals(deviceId, deviceMergedAttrBC.getEntityId()); + Assertions.assertEquals(2_000L, deviceMergedAttrBC.getBody().get("ts").asLong()); + Assertions.assertEquals(2, getIntValue(deviceMergedAttrBC.getBody(), "b")); + Assertions.assertEquals(2, getIntValue(deviceMergedAttrBC.getBody(), "c")); + Assertions.assertNull(getIntValue(deviceMergedAttrBC.getBody(), "a")); + + EdgeEvent deviceMergedAttrAD = merged.get(1); + Assertions.assertEquals(3, deviceMergedAttrAD.getSeqId()); + Assertions.assertEquals(deviceId, deviceMergedAttrAD.getEntityId()); + Assertions.assertEquals(3_000L, deviceMergedAttrAD.getBody().get("ts").asLong()); + Assertions.assertEquals(3, getIntValue(deviceMergedAttrAD.getBody(), "a")); + Assertions.assertEquals(3, getIntValue(deviceMergedAttrAD.getBody(), "d")); + + EdgeEvent mergedDeviceUpdate = merged.get(2); + Assertions.assertEquals(4, mergedDeviceUpdate.getSeqId()); + Assertions.assertEquals(EdgeEventActionType.UPDATED, mergedDeviceUpdate.getAction()); + + EdgeEvent assetMergedAttrBC = merged.get(3); + Assertions.assertEquals(7, assetMergedAttrBC.getSeqId()); + Assertions.assertEquals(assetId, assetMergedAttrBC.getEntityId()); + Assertions.assertEquals(7_000L, assetMergedAttrBC.getBody().get("ts").asLong()); + Assertions.assertEquals(7, getIntValue(assetMergedAttrBC.getBody(), "b")); + Assertions.assertEquals(7, getIntValue(assetMergedAttrBC.getBody(), "c")); + Assertions.assertNull(getIntValue(assetMergedAttrBC.getBody(), "a")); + + EdgeEvent assetMergedAttrAD = merged.get(4); + Assertions.assertEquals(8, assetMergedAttrAD.getSeqId()); + Assertions.assertEquals(assetId, assetMergedAttrAD.getEntityId()); + Assertions.assertEquals(8_000L, assetMergedAttrAD.getBody().get("ts").asLong()); + Assertions.assertEquals(8, getIntValue(assetMergedAttrAD.getBody(), "a")); + Assertions.assertEquals(8, getIntValue(assetMergedAttrAD.getBody(), "d")); + } + + @Test + public void testMergeDownlinkDuplicates_attrBodyHasNoTs_returnOriginalList() { + UUID deviceId = UUID.randomUUID(); + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + + var deviceAttrUpdate1 = createEdgeEvent(tenantId, 1, EdgeEventActionType.ATTRIBUTES_UPDATED, + deviceId, EdgeEventType.DEVICE, createAttrBodyWithoutTs("{\"a\":1,\"b\":1,\"d\":1}")); + var deviceAttrUpdate2 = createEdgeEvent(tenantId, 2, EdgeEventActionType.ATTRIBUTES_UPDATED, + deviceId, EdgeEventType.DEVICE, createAttrBodyWithoutTs("{\"a\":2,\"b\":2,\"c\":2}")); + var deviceAttrUpdate3 = createEdgeEvent(tenantId, 3, EdgeEventActionType.ATTRIBUTES_UPDATED, + deviceId, EdgeEventType.DEVICE, createAttrBodyWithoutTs("{\"a\":3,\"d\":3}")); + + List input = List.of(deviceAttrUpdate1, deviceAttrUpdate2, deviceAttrUpdate3); + List merged = EdgeMsgConstructorUtils.mergeAndFilterDownlinkDuplicates(input); + + Assertions.assertEquals(3, merged.size()); + Assertions.assertEquals(deviceAttrUpdate1, merged.get(0)); + Assertions.assertEquals(deviceAttrUpdate2, merged.get(1)); + Assertions.assertEquals(deviceAttrUpdate3, merged.get(2)); + } + + private Integer getIntValue(JsonNode body, String key) { + return body.get("kv").get(key) != null ? body.get("kv").get(key).asInt() : null; + } + + private static JsonNode createAttrBodyWithoutTs(String kvJson) { + return JacksonUtil.toJsonNode("{\"kv\":" + kvJson + "}"); + } + + private static JsonNode createAttrBody(long ts, String kvJson) { + return JacksonUtil.toJsonNode("{\"ts\":" + ts + ",\"kv\":" + kvJson + "}"); + } + + private static EdgeEvent createEdgeEvent(TenantId tenantId, + long seqId, + EdgeEventActionType action, + UUID entityId, + EdgeEventType type, + JsonNode body) { + EdgeEvent edgeEvent = new EdgeEvent(); + edgeEvent.setSeqId(seqId); + edgeEvent.setTenantId(tenantId); + edgeEvent.setAction(action); + edgeEvent.setEntityId(entityId); + edgeEvent.setType(type); + edgeEvent.setBody(body); + return edgeEvent; + } } diff --git a/application/src/test/java/org/thingsboard/server/service/edge/EdgeStatsTest.java b/application/src/test/java/org/thingsboard/server/service/edge/EdgeStatsTest.java index 25ff0f1b5d..f1f0965a0d 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/EdgeStatsTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/EdgeStatsTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ 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.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; @@ -44,9 +45,11 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.thingsboard.server.dao.edge.stats.EdgeStatsKey.DOWNLINK_MSGS_ADDED; @@ -58,6 +61,16 @@ import static org.thingsboard.server.dao.edge.stats.EdgeStatsKey.DOWNLINK_MSGS_T @ExtendWith(MockitoExtension.class) public class EdgeStatsTest { + private static final int TTL_DAYS = 30; + private static final long REPORT_INTERVAL_MILLIS = 600_000L; + + private static final long EXPECTED_MSGS_ADDED = 5L; + private static final long EXPECTED_MSGS_PUSHED = 3L; + private static final long EXPECTED_MSGS_PERMANENTLY_FAILED = 1L; + private static final long EXPECTED_MSGS_TMP_FAILED = 0L; + private static final long EXPECTED_MSGS_LAG = 10L; + private static final long EXPECTED_MSGS_KAFKA_LAG = 15L; + @Mock private TimeseriesService tsService; @Mock @@ -66,108 +79,100 @@ public class EdgeStatsTest { private EdgeStatsCounterService statsCounterService; private EdgeStatsService edgeStatsService; + @Captor + private ArgumentCaptor> captor; + private final TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); private final EdgeId edgeId = new EdgeId(UUID.randomUUID()); @BeforeEach void setUp() { - edgeStatsService = new EdgeStatsService( + edgeStatsService = createEdgeStatsService(Optional.empty()); + } + + private EdgeStatsService createEdgeStatsService(Optional kafkaAdmin) { + EdgeStatsService service = new EdgeStatsService( tsService, statsCounterService, topicService, - Optional.empty() + kafkaAdmin ); - - ReflectionTestUtils.setField(edgeStatsService, "edgesStatsTtlDays", 30); - ReflectionTestUtils.setField(edgeStatsService, "reportIntervalMillis", 600_000L); + ReflectionTestUtils.setField(service, "edgesStatsTtlDays", TTL_DAYS); + ReflectionTestUtils.setField(service, "reportIntervalMillis", REPORT_INTERVAL_MILLIS); + return service; } @Test public void testReportStatsSavesTelemetry() { - // given + // GIVEN + setupCounters(); + + // WHEN + edgeStatsService.reportStats(); + + // THEN + Map counters = verifyCounters(); + Assertions.assertEquals(EXPECTED_MSGS_LAG, counters.get(DOWNLINK_MSGS_LAG.getKey()).longValue()); + } + + @Test + public void testReportStatsWithKafkaLag() { + // GIVEN + setupCounters(); + setupKafkaLag(); + + // WHEN + edgeStatsService.reportStats(); + + // THEN + Map valuesByKey = verifyCounters(); + Assertions.assertEquals(EXPECTED_MSGS_KAFKA_LAG, valuesByKey.get(DOWNLINK_MSGS_LAG.getKey())); + } + + private void setupCounters() { MsgCounters counters = new MsgCounters(tenantId); - counters.getMsgsAdded().set(5); - counters.getMsgsPushed().set(3); - counters.getMsgsPermanentlyFailed().set(1); - counters.getMsgsTmpFailed().set(0); - counters.getMsgsLag().set(10); + counters.getMsgsAdded().set(EXPECTED_MSGS_ADDED); + counters.getMsgsPushed().set(EXPECTED_MSGS_PUSHED); + counters.getMsgsPermanentlyFailed().set(EXPECTED_MSGS_PERMANENTLY_FAILED); + counters.getMsgsTmpFailed().set(EXPECTED_MSGS_TMP_FAILED); + counters.getMsgsLag().set(EXPECTED_MSGS_LAG); ConcurrentHashMap countersByEdge = new ConcurrentHashMap<>(); countersByEdge.put(edgeId, counters); - when(statsCounterService.getCounterByEdge()).thenReturn(countersByEdge); + when(statsCounterService.getMsgCountersByEdge()).thenReturn(countersByEdge); - ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); when(tsService.save(eq(tenantId), eq(edgeId), captor.capture(), anyLong())) .thenReturn(Futures.immediateFuture(mock(TimeseriesSaveResult.class))); + } - // when - edgeStatsService.reportStats(); + private Map verifyCounters() { + verify(tsService, times(1)).save(eq(tenantId), eq(edgeId), anyList(), anyLong()); + verify(statsCounterService, times(1)).clear(edgeId); - // then List entries = captor.getValue(); Assertions.assertEquals(5, entries.size()); Map valuesByKey = entries.stream() .collect(Collectors.toMap(TsKvEntry::getKey, e -> e.getLongValue().orElse(-1L))); - Assertions.assertEquals(5L, valuesByKey.get(DOWNLINK_MSGS_ADDED.getKey()).longValue()); - Assertions.assertEquals(3L, valuesByKey.get(DOWNLINK_MSGS_PUSHED.getKey()).longValue()); - Assertions.assertEquals(1L, valuesByKey.get(DOWNLINK_MSGS_PERMANENTLY_FAILED.getKey()).longValue()); - Assertions.assertEquals(0L, valuesByKey.get(DOWNLINK_MSGS_TMP_FAILED.getKey()).longValue()); - Assertions.assertEquals(10L, valuesByKey.get(DOWNLINK_MSGS_LAG.getKey()).longValue()); - - - verify(statsCounterService).clear(edgeId); + Assertions.assertEquals(EXPECTED_MSGS_ADDED, valuesByKey.get(DOWNLINK_MSGS_ADDED.getKey()).longValue()); + Assertions.assertEquals(EXPECTED_MSGS_PUSHED, valuesByKey.get(DOWNLINK_MSGS_PUSHED.getKey()).longValue()); + Assertions.assertEquals(EXPECTED_MSGS_PERMANENTLY_FAILED, valuesByKey.get(DOWNLINK_MSGS_PERMANENTLY_FAILED.getKey()).longValue()); + Assertions.assertEquals(EXPECTED_MSGS_TMP_FAILED, valuesByKey.get(DOWNLINK_MSGS_TMP_FAILED.getKey()).longValue()); + return valuesByKey; } - @Test - public void testReportStatsWithKafkaLag() { - // given - MsgCounters counters = new MsgCounters(tenantId); - counters.getMsgsAdded().set(2); - counters.getMsgsPushed().set(2); - counters.getMsgsPermanentlyFailed().set(0); - counters.getMsgsTmpFailed().set(1); - counters.getMsgsLag().set(0); - - ConcurrentHashMap countersByEdge = new ConcurrentHashMap<>(); - countersByEdge.put(edgeId, counters); - - // mocks - when(statsCounterService.getCounterByEdge()).thenReturn(countersByEdge); - + private void setupKafkaLag() { String topic = "edge-topic"; TopicPartitionInfo partitionInfo = new TopicPartitionInfo(topic, tenantId, 0, false); when(topicService.buildEdgeEventNotificationsTopicPartitionInfo(tenantId, edgeId)).thenReturn(partitionInfo); KafkaAdmin kafkaAdmin = mock(KafkaAdmin.class); when(kafkaAdmin.getTotalLagForGroupsBulk(Set.of(topic))) - .thenReturn(Map.of(topic, 15L)); - - ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); - when(tsService.save(eq(tenantId), eq(edgeId), captor.capture(), anyLong())) - .thenReturn(Futures.immediateFuture(mock(TimeseriesSaveResult.class))); - - edgeStatsService = new EdgeStatsService( - tsService, - statsCounterService, - topicService, - Optional.of(kafkaAdmin) - ); - ReflectionTestUtils.setField(edgeStatsService, "edgesStatsTtlDays", 30); - ReflectionTestUtils.setField(edgeStatsService, "reportIntervalMillis", 600_000L); - - // when - edgeStatsService.reportStats(); - - // then - List entries = captor.getValue(); - Map valuesByKey = entries.stream() - .collect(Collectors.toMap(TsKvEntry::getKey, e -> e.getLongValue().orElse(-1L))); + .thenReturn(Map.of(topic, EXPECTED_MSGS_KAFKA_LAG)); - Assertions.assertEquals(15L, valuesByKey.get(DOWNLINK_MSGS_LAG.getKey())); - verify(statsCounterService).clear(edgeId); + edgeStatsService = createEdgeStatsService(Optional.of(kafkaAdmin)); } } diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java index 2f244807bd..adf664e7cf 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,8 +19,8 @@ import com.google.common.collect.Lists; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.id.CustomerId; @@ -61,7 +61,7 @@ public class EdqsEntityServiceTest extends EntityServiceTest { @Autowired private EdqsService edqsService; - @MockBean + @MockitoBean private EdqsRocksDb edqsRocksDb; @Before diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java index 8e95a68110..c54ffe4a07 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java index b6be136f82..75e3627464 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmServiceTest.java index 670ab390ac..96e4444ffd 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -62,6 +62,9 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import static org.thingsboard.server.common.data.alarm.AlarmCommentSubType.ASSIGNED_TO_USER; +import static org.thingsboard.server.common.data.alarm.AlarmCommentSubType.UNASSIGNED_BY_USER; +import static org.thingsboard.server.common.data.alarm.AlarmCommentSubType.UNASSIGNED_FROM_DELETED_USER; @SpringJUnitConfig(DefaultTbAlarmService.class) class DefaultTbAlarmServiceTest { @@ -217,9 +220,10 @@ class DefaultTbAlarmServiceTest { service.unassign(new Alarm(), 0L, user); ObjectNode commentNode = JacksonUtil.newObjectNode(); - commentNode.put("subtype", "ASSIGN"); - commentNode.put("text", "Alarm was unassigned by user " + user.getTitle()); - commentNode.put("userId", user.getId().getId().toString()); + commentNode.put("text", String.format(UNASSIGNED_BY_USER.getText(), user.getTitle())); + commentNode.put("subtype", UNASSIGNED_BY_USER.name()); + commentNode.put("userName", user.getTitle()); + AlarmComment expectedAlarmComment = AlarmComment.builder() .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) @@ -243,8 +247,10 @@ class DefaultTbAlarmServiceTest { service.unassignDeletedUserAlarms(tenantId, user.getId(), user.getTitle(), List.of(alarm.getUuidId()), System.currentTimeMillis()); ObjectNode commentNode = JacksonUtil.newObjectNode(); - commentNode.put("subtype", "ASSIGN"); - commentNode.put("text", String.format("Alarm was unassigned because user %s - was deleted", user.getTitle())); + commentNode.put("text", String.format(UNASSIGNED_FROM_DELETED_USER.getText(), user.getTitle())); + commentNode.put("subtype", UNASSIGNED_FROM_DELETED_USER.name()); + commentNode.put("userName", user.getTitle()); + AlarmComment expectedAlarmComment = AlarmComment.builder() .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/alarmComment/DefaultTbAlarmCommentServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/alarmComment/DefaultTbAlarmCommentServiceTest.java index 3c00c2957a..2c2d2bbdc3 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/alarmComment/DefaultTbAlarmCommentServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/alarmComment/DefaultTbAlarmCommentServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/dashboard/DashboardSyncServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/dashboard/DashboardSyncServiceTest.java index 8f47a478ff..46129f7cca 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/dashboard/DashboardSyncServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/dashboard/DashboardSyncServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewServiceTest.java index 0d9d45ea0e..1e8023a3a6 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java b/application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java index a276a9c5ff..cf37afad5c 100644 --- a/application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/install/InstallScriptsTest.java b/application/src/test/java/org/thingsboard/server/service/install/InstallScriptsTest.java index af7c33aabf..fa675fc3f8 100644 --- a/application/src/test/java/org/thingsboard/server/service/install/InstallScriptsTest.java +++ b/application/src/test/java/org/thingsboard/server/service/install/InstallScriptsTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaServiceTest.java b/application/src/test/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaServiceTest.java index 55d96bd21e..e2a9ce1c5a 100644 --- a/application/src/test/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/install/update/DefaultDataUpdateServiceTest.java b/application/src/test/java/org/thingsboard/server/service/install/update/DefaultDataUpdateServiceTest.java deleted file mode 100644 index abfab84491..0000000000 --- a/application/src/test/java/org/thingsboard/server/service/install/update/DefaultDataUpdateServiceTest.java +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Copyright © 2016-2025 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.service.install.update; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.ActiveProfiles; -import org.thingsboard.common.util.JacksonUtil; - -import java.io.IOException; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.willCallRealMethod; - -@ActiveProfiles("install") -@SpringBootTest(classes = DefaultDataUpdateService.class) -class DefaultDataUpdateServiceTest { - - @MockBean - DefaultDataUpdateService service; - - @BeforeEach - void setUp() { - willCallRealMethod().given(service).convertDeviceProfileAlarmRulesForVersion330(any()); - willCallRealMethod().given(service).convertDeviceProfileForVersion330(any()); - } - - JsonNode readFromResource(String resourceName) throws IOException { - return JacksonUtil.OBJECT_MAPPER.readTree(this.getClass().getClassLoader().getResourceAsStream(resourceName)); - } - - @Test - void convertDeviceProfileAlarmRulesForVersion330FirstRun() throws IOException { - JsonNode spec = readFromResource("update/330/device_profile_001_in.json"); - JsonNode expected = readFromResource("update/330/device_profile_001_out.json"); - - assertThat(service.convertDeviceProfileForVersion330(spec.get("profileData"))).isTrue(); - assertThat(spec.toPrettyString()).isEqualTo(expected.toPrettyString()); // use IDE feature - } - - @Test - void convertDeviceProfileAlarmRulesForVersion330SecondRun() throws IOException { - JsonNode spec = readFromResource("update/330/device_profile_001_out.json"); - JsonNode expected = readFromResource("update/330/device_profile_001_out.json"); - - assertThat(service.convertDeviceProfileForVersion330(spec.get("profileData"))).isFalse(); - assertThat(spec.toPrettyString()).isEqualTo(expected.toPrettyString()); // use IDE feature - } - - @Test - void convertDeviceProfileAlarmRulesForVersion330EmptyJson() throws JsonProcessingException { - JsonNode spec = JacksonUtil.toJsonNode("{ }"); - JsonNode expected = JacksonUtil.toJsonNode("{ }"); - - assertThat(service.convertDeviceProfileForVersion330(spec)).isFalse(); - assertThat(spec.toPrettyString()).isEqualTo(expected.toPrettyString()); - } - - @Test - void convertDeviceProfileAlarmRulesForVersion330AlarmNodeNull() throws JsonProcessingException { - JsonNode spec = JacksonUtil.toJsonNode("{ \"alarms\" : null }"); - JsonNode expected = JacksonUtil.toJsonNode("{ \"alarms\" : null }"); - - assertThat(service.convertDeviceProfileForVersion330(spec)).isFalse(); - assertThat(spec.toPrettyString()).isEqualTo(expected.toPrettyString()); - } - - @Test - void convertDeviceProfileAlarmRulesForVersion330NoAlarmNode() throws JsonProcessingException { - JsonNode spec = JacksonUtil.toJsonNode("{ \"configuration\": { \"type\": \"DEFAULT\" } }"); - JsonNode expected = JacksonUtil.toJsonNode("{ \"configuration\": { \"type\": \"DEFAULT\" } }"); - - assertThat(service.convertDeviceProfileForVersion330(spec)).isFalse(); - assertThat(spec.toPrettyString()).isEqualTo(expected.toPrettyString()); - } - -} diff --git a/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java b/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java index 7806215a51..c17a9f6bfb 100644 --- a/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -107,6 +107,8 @@ public class JobManagerTest extends AbstractControllerTest { assertThat(job.getResult().getSuccessfulCount()).isEqualTo(tasksCount); assertThat(job.getResult().getResults()).isEmpty(); assertThat(job.getResult().getCompletedCount()).isEqualTo(tasksCount); + assertThat(job.getResult().getStartTs()).isPositive(); + assertThat(job.getResult().getFinishTs()).isPositive(); }); } @@ -133,6 +135,8 @@ public class JobManagerTest extends AbstractControllerTest { assertThat(failure.getError()).isEqualTo("error3"); // last error }); assertThat(jobResult.getCompletedCount()).isEqualTo(jobResult.getTotalCount()); + assertThat(job.getResult().getStartTs()).isPositive(); + assertThat(job.getResult().getFinishTs()).isPositive(); }); } @@ -168,6 +172,9 @@ public class JobManagerTest extends AbstractControllerTest { assertThat(job.getResult().getDiscardedCount()).isBetween(1, tasksCount); assertThat(job.getResult().getTotalCount()).isEqualTo(tasksCount); assertThat(job.getResult().getCompletedCount()).isEqualTo(tasksCount); + assertThat(job.getResult().getStartTs()).isPositive(); + assertThat(job.getResult().getFinishTs()).isPositive(); + assertThat(job.getResult().getCancellationTs()).isPositive(); }); } @@ -194,6 +201,9 @@ public class JobManagerTest extends AbstractControllerTest { assertThat(job.getResult().getTotalCount()).isEqualTo(1); assertThat(job.getResult().getDiscardedCount()).isEqualTo(1); assertThat(job.getResult().getFailedCount()).isZero(); + assertThat(job.getResult().getStartTs()).isPositive(); + assertThat(job.getResult().getFinishTs()).isPositive(); + assertThat(job.getResult().getCancellationTs()).isPositive(); }); } @@ -222,6 +232,9 @@ public class JobManagerTest extends AbstractControllerTest { assertThat(job.getResult().getDiscardedCount()).isBetween(1, tasksCount - 1); assertThat(job.getResult().getTotalCount()).isEqualTo(tasksCount); assertThat(job.getResult().getCompletedCount()).isEqualTo(tasksCount); + assertThat(job.getResult().getStartTs()).isPositive(); + assertThat(job.getResult().getFinishTs()).isPositive(); + assertThat(job.getResult().getCancellationTs()).isPositive(); }); } @@ -275,6 +288,8 @@ public class JobManagerTest extends AbstractControllerTest { assertThat(job.getResult().getTotalCount()).isEqualTo(tasksCount); assertThat(job.getEntityId()).isEqualTo(jobEntity.getId()); assertThat(job.getEntityName()).isEqualTo(jobEntity.getName()); + assertThat(job.getResult().getStartTs()).isPositive(); + assertThat(job.getResult().getFinishTs()).isPositive(); } }); @@ -385,6 +400,8 @@ public class JobManagerTest extends AbstractControllerTest { assertThat(job.getResult().getTotalCount()).isEqualTo(submittedTasks); assertThat(job.getResult().getFailedCount()).isZero(); assertThat(job.getResult().getDiscardedCount()).isZero(); + assertThat(job.getResult().getStartTs()).isPositive(); + assertThat(job.getResult().getFinishTs()).isPositive(); }); } @@ -425,6 +442,8 @@ public class JobManagerTest extends AbstractControllerTest { assertThat(job.getResult().getTotalCount()).isEqualTo(totalTasksCount); assertThat(job.getResult().getResults()).isEmpty(); assertThat(job.getConfiguration().getToReprocess()).isNullOrEmpty(); + assertThat(job.getResult().getStartTs()).isPositive(); + assertThat(job.getResult().getFinishTs()).isPositive(); }); } @@ -449,6 +468,8 @@ public class JobManagerTest extends AbstractControllerTest { assertThat(jobResult.getSuccessfulCount()).isEqualTo(successfulTasks); assertThat(jobResult.getFailedCount()).isEqualTo(failedTasks + permanentlyFailedTasks); assertThat(jobResult.getTotalCount()).isEqualTo(totalTasksCount); + assertThat(jobResult.getStartTs()).isPositive(); + assertThat(jobResult.getFinishTs()).isPositive(); List failures = getFailures(jobResult); for (int i = 0, taskNumber = successfulTasks + 1; taskNumber <= totalTasksCount; i++, taskNumber++) { @@ -467,6 +488,8 @@ public class JobManagerTest extends AbstractControllerTest { assertThat(jobResult.getSuccessfulCount()).isEqualTo(successfulTasks + failedTasks); assertThat(jobResult.getFailedCount()).isEqualTo(permanentlyFailedTasks); assertThat(jobResult.getTotalCount()).isEqualTo(totalTasksCount); + assertThat(jobResult.getStartTs()).isPositive(); + assertThat(jobResult.getFinishTs()).isPositive(); List failures = getFailures(jobResult); for (int i = 0, taskNumber = successfulTasks + failedTasks + 1; taskNumber <= totalTasksCount; i++, taskNumber++) { @@ -478,6 +501,23 @@ public class JobManagerTest extends AbstractControllerTest { }); } + @Test + public void testSubmitJob_zeroTasks() { + JobId jobId = submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(0) + .build()).getId(); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Job job = findJobById(jobId); + assertThat(job.getStatus()).isEqualTo(JobStatus.COMPLETED); + assertThat(job.getResult().getSuccessfulCount()).isEqualTo(0); + assertThat(job.getResult().getResults()).isEmpty(); + assertThat(job.getResult().getCompletedCount()).isEqualTo(0); + assertThat(job.getResult().getStartTs()).isPositive(); + assertThat(job.getResult().getFinishTs()).isPositive(); + }); + } + private Job submitJob(DummyJobConfiguration configuration) { return submitJob(configuration, "test-job"); } diff --git a/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest_EntityPartitioningStrategy.java b/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest_EntityPartitioningStrategy.java index c1b8f911ff..ebf72617be 100644 --- a/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest_EntityPartitioningStrategy.java +++ b/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest_EntityPartitioningStrategy.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/job/TestTaskProcessor.java b/application/src/test/java/org/thingsboard/server/service/job/TestTaskProcessor.java index fdaec648b5..f237f860fe 100644 --- a/application/src/test/java/org/thingsboard/server/service/job/TestTaskProcessor.java +++ b/application/src/test/java/org/thingsboard/server/service/job/TestTaskProcessor.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/limits/RateLimitServiceTest.java b/application/src/test/java/org/thingsboard/server/service/limits/RateLimitServiceTest.java index 674b7bfdb9..b8b9bb4bc5 100644 --- a/application/src/test/java/org/thingsboard/server/service/limits/RateLimitServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/limits/RateLimitServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/mail/TbMailSenderTest.java b/application/src/test/java/org/thingsboard/server/service/mail/TbMailSenderTest.java index d77e2bfc48..2083951492 100644 --- a/application/src/test/java/org/thingsboard/server/service/mail/TbMailSenderTest.java +++ b/application/src/test/java/org/thingsboard/server/service/mail/TbMailSenderTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java index f238a1bbc1..f5607415fa 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,9 +19,8 @@ import com.fasterxml.jackson.core.type.TypeReference; import org.apache.commons.lang3.RandomStringUtils; import org.junit.After; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.util.Pair; -import org.thingsboard.rule.engine.api.MailService; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.thingsboard.rule.engine.api.notification.SlackService; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.id.NotificationRequestId; @@ -69,10 +68,8 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest protected NotificationApiWsClient wsClient; protected NotificationApiWsClient otherWsClient; - @MockBean + @MockitoBean protected SlackService slackService; - @Autowired - protected MailService mailService; @Autowired protected NotificationRuleService notificationRuleService; @@ -225,4 +222,5 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest public NotificationApiWsClient getAnotherWsClient() { return (NotificationApiWsClient) super.getAnotherWsClient(); } + } diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java index 2f5f1572b6..fc42ae247c 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,8 +24,8 @@ import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.HttpEntity; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.ResultActions; import org.springframework.web.client.RestTemplate; import org.thingsboard.common.util.JacksonUtil; @@ -125,7 +125,7 @@ public class NotificationApiTest extends AbstractNotificationApiTest { private NotificationCenter notificationCenter; @Autowired private MicrosoftTeamsNotificationChannel microsoftTeamsNotificationChannel; - @MockBean + @MockitoBean private FirebaseService firebaseService; private static final String TEST_MOBILE_TOKEN = "tenantFcmToken"; diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiWsClient.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiWsClient.java index 240691d9c2..b538972dd7 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiWsClient.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiWsClient.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java index bab70ea505..89f743aee4 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,13 +21,15 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Before; import org.junit.Test; import org.junit.function.ThrowingRunnable; +import org.mockito.MockedStatic; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.data.util.Pair; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.SystemUtil; import org.thingsboard.server.cache.limits.RateLimitService; -import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityType; @@ -39,17 +41,19 @@ import org.thingsboard.server.common.data.alarm.AlarmCommentType; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; import org.thingsboard.server.common.data.alarm.AlarmSeverity; import org.thingsboard.server.common.data.alarm.AlarmStatus; +import org.thingsboard.server.common.data.alarm.rule.AlarmRule; +import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration; import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; import org.thingsboard.server.common.data.device.data.DeviceData; -import org.thingsboard.server.common.data.device.profile.AlarmCondition; -import org.thingsboard.server.common.data.device.profile.AlarmConditionFilter; -import org.thingsboard.server.common.data.device.profile.AlarmConditionFilterKey; -import org.thingsboard.server.common.data.device.profile.AlarmConditionKeyType; -import org.thingsboard.server.common.data.device.profile.AlarmRule; -import org.thingsboard.server.common.data.device.profile.DeviceProfileAlarm; -import org.thingsboard.server.common.data.device.profile.SimpleAlarmConditionSpec; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.DeviceId; @@ -87,9 +91,6 @@ import org.thingsboard.server.common.data.notification.targets.platform.SystemAd import org.thingsboard.server.common.data.notification.template.NotificationTemplate; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.common.data.query.BooleanFilterPredicate; -import org.thingsboard.server.common.data.query.EntityKeyValueType; -import org.thingsboard.server.common.data.query.FilterPredicateValue; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.common.data.security.Authority; @@ -106,12 +107,11 @@ import org.thingsboard.server.service.system.DefaultSystemInfoService; import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; import java.lang.reflect.Method; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; -import java.util.TreeMap; import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; @@ -123,6 +123,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.offset; import static org.assertj.core.api.InstanceOfAssertFactories.type; import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.mockStatic; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.common.data.notification.rule.trigger.config.AlarmAssignmentNotificationRuleTriggerConfig.Action.ASSIGNED; import static org.thingsboard.server.common.data.notification.rule.trigger.config.AlarmAssignmentNotificationRuleTriggerConfig.Action.UNASSIGNED; @@ -137,7 +138,7 @@ import static org.thingsboard.server.common.data.notification.rule.trigger.confi }) public class NotificationRuleApiTest extends AbstractNotificationApiTest { - @SpyBean + @MockitoSpyBean private AlarmSubscriptionService alarmSubscriptionService; @Autowired private DefaultSystemInfoService systemInfoService; @@ -193,7 +194,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { @Test public void testNotificationRuleProcessing_alarmTrigger() throws Exception { String notificationSubject = "Alarm type: ${alarmType}, status: ${alarmStatus}, " + - "severity: ${alarmSeverity}, deviceId: ${alarmOriginatorId}"; + "severity: ${alarmSeverity}, deviceId: ${alarmOriginatorId}, details: ${details.data}."; String notificationText = "Status: ${alarmStatus}, severity: ${alarmSeverity}"; NotificationTemplate notificationTemplate = createNotificationTemplate(NotificationType.ALARM, notificationSubject, notificationText, NotificationDeliveryMethod.WEB); @@ -221,12 +222,12 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { clients.put(delay, userAndClient.getSecond()); } notificationRule.setRecipientsConfig(recipientsConfig); - notificationRule = saveNotificationRule(notificationRule); + saveNotificationRule(notificationRule); String alarmType = "myBoolIsTrue"; DeviceProfile deviceProfile = createDeviceProfileWithAlarmRules(alarmType); - Device device = createDevice("Device 1", deviceProfile.getName(), "1234"); + Device device = createDevice("Device 1", deviceProfile.getName(), "label", "1234"); clients.values().forEach(wsClient -> { wsClient.subscribeForUnreadNotifications(10).waitForReply(true); @@ -234,8 +235,8 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { }); JsonNode attr = JacksonUtil.newObjectNode() - .set("bool", BooleanNode.TRUE); - doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE, attr); + .set("createAlarm", BooleanNode.TRUE); + postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, attr.toString()); await().atMost(10, TimeUnit.SECONDS) .until(() -> alarmSubscriptionService.findLatestByOriginatorAndType(tenantId, device.getId(), alarmType) != null); @@ -250,7 +251,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { assertThat(actualDelay).isCloseTo(expectedDelay, offset(2.0)); assertThat(notification.getSubject()).isEqualTo("Alarm type: " + alarmType + ", status: " + AlarmStatus.ACTIVE_UNACK + ", " + - "severity: " + AlarmSeverity.CRITICAL.toString().toLowerCase() + ", deviceId: " + device.getId()); + "severity: " + AlarmSeverity.CRITICAL.toString().toLowerCase() + ", deviceId: " + device.getId() + ", details: attribute is true."); assertThat(notification.getText()).isEqualTo("Status: " + AlarmStatus.ACTIVE_UNACK + ", severity: " + AlarmSeverity.CRITICAL.toString().toLowerCase()); assertThat(notification.getType()).isEqualTo(NotificationType.ALARM); @@ -270,7 +271,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { wsClient.waitForUpdate(true); Notification updatedNotification = wsClient.getLastDataUpdate().getUpdate(); assertThat(updatedNotification.getSubject()).isEqualTo("Alarm type: " + alarmType + ", status: " + expectedStatus + ", " + - "severity: " + expectedSeverity.toString().toLowerCase() + ", deviceId: " + device.getId()); + "severity: " + expectedSeverity.toString().toLowerCase() + ", deviceId: " + device.getId() + ", details: attribute is true."); assertThat(updatedNotification.getText()).isEqualTo("Status: " + expectedStatus + ", severity: " + expectedSeverity.toString().toLowerCase()); wsClient.close(); @@ -341,8 +342,8 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { getWsClient().subscribeForUnreadNotifications(10).waitForReply(true); getWsClient().registerWaitForUpdate(); JsonNode attr = JacksonUtil.newObjectNode() - .set("bool", BooleanNode.TRUE); - doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE, attr); + .set("createAlarm", BooleanNode.TRUE); + postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, attr.toString()); await().atMost(10, TimeUnit.SECONDS) .until(() -> alarmSubscriptionService.findLatestByOriginatorAndType(tenantId, device.getId(), alarmType) != null); @@ -516,10 +517,10 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { .notifyOn(Set.of(ASSIGNED, UNASSIGNED)) .build(); NotificationTarget target = createNotificationTarget(tenantAdminUserId); - String template = "${userEmail} ${action} alarm on ${alarmOriginatorEntityType} '${alarmOriginatorName}'. Assignee: ${assigneeEmail}"; + String template = "${userEmail} ${action} alarm on ${alarmOriginatorEntityType} '${alarmOriginatorName}' with label '${alarmOriginatorLabel}'. Assignee: ${assigneeEmail}"; createNotificationRule(triggerConfig, "Test", template, target.getId()); - Device device = createDevice("Device A", "123"); + Device device = createDevice("Device A", "default", "test", "123"); Alarm alarm = Alarm.builder() .tenantId(tenantId) .originator(device.getId()) @@ -536,7 +537,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { doPost("/api/alarm/" + alarmId + "/assign/" + tenantAdminUserId).andExpect(status().isOk()); }, notification -> { assertThat(notification.getText()).isEqualTo( - TENANT_ADMIN_EMAIL + " assigned alarm on Device 'Device A'. Assignee: " + TENANT_ADMIN_EMAIL + TENANT_ADMIN_EMAIL + " assigned alarm on Device 'Device A' with label 'test'. Assignee: " + TENANT_ADMIN_EMAIL ); }); @@ -544,7 +545,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { doDelete("/api/alarm/" + alarmId + "/assign").andExpect(status().isOk()); }, notification -> { assertThat(notification.getText()).isEqualTo( - TENANT_ADMIN_EMAIL + " unassigned alarm on Device 'Device A'. Assignee: " + TENANT_ADMIN_EMAIL + " unassigned alarm on Device 'Device A' with label 'test'. Assignee: " ); }); } @@ -799,9 +800,14 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { createNotificationRule(triggerConfig, "Warning: ${resource} shortage", "${resource} shortage", createNotificationTarget(tenantAdminUserId).getId()); loginTenantAdmin(); - Method method = DefaultSystemInfoService.class.getDeclaredMethod("saveCurrentMonolithSystemInfo"); - method.setAccessible(true); - method.invoke(systemInfoService); + // Mock SystemUtil to return 15% memory usage (exceeds 1% threshold) + try (MockedStatic mockedSystemUtil = mockStatic(SystemUtil.class)) { + mockedSystemUtil.when(SystemUtil::getMemoryUsage).thenReturn(Optional.of(15)); + + Method method = DefaultSystemInfoService.class.getDeclaredMethod("saveCurrentMonolithSystemInfo"); + method.setAccessible(true); + method.invoke(systemInfoService); + } await().atMost(10, TimeUnit.SECONDS).until(() -> getMyNotifications(false, 100).size() == 1); Notification notification = getMyNotifications(false, 100).get(0); @@ -812,13 +818,13 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { @Test public void testNotificationsDeduplication_resourcesShortage() throws Exception { loginSysAdmin(); + NotificationTarget sysadmins = createNotificationTarget(new SystemAdministratorsFilter()); ResourcesShortageNotificationRuleTriggerConfig triggerConfig = ResourcesShortageNotificationRuleTriggerConfig.builder() .ramThreshold(0.01f) .cpuThreshold(1f) .storageThreshold(1f) .build(); - createNotificationRule(triggerConfig, "Warning: ${resource} shortage", "${resource} shortage", createNotificationTarget(tenantAdminUserId).getId()); - loginTenantAdmin(); + createNotificationRule(triggerConfig, "Warning: ${resource} shortage", "${resource} shortage", sysadmins.getId()); assertThat(getMyNotifications(false, 100)).size().isZero(); for (int i = 0; i < 10; i++) { @@ -848,22 +854,29 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { @Test public void testNotificationsResourcesShortage_whenThresholdChangeToMatchingFilter_thenSendNotification() throws Exception { loginSysAdmin(); + NotificationTarget sysadmins = createNotificationTarget(new SystemAdministratorsFilter()); ResourcesShortageNotificationRuleTriggerConfig triggerConfig = ResourcesShortageNotificationRuleTriggerConfig.builder() - .ramThreshold(1f) - .cpuThreshold(1f) - .storageThreshold(1f) + .ramThreshold(0.99f) + .cpuThreshold(0.99f) + .storageThreshold(0.99f) .build(); - NotificationRule rule = createNotificationRule(triggerConfig, "Warning: ${resource} shortage", "${resource} shortage", createNotificationTarget(tenantAdminUserId).getId()); - loginTenantAdmin(); - - Method method = DefaultSystemInfoService.class.getDeclaredMethod("saveCurrentMonolithSystemInfo"); - method.setAccessible(true); - method.invoke(systemInfoService); + NotificationRule rule = createNotificationRule(triggerConfig, "Warning: ${resource} shortage", "${resource} shortage", sysadmins.getId()); + + // Mock SystemUtil to return 15% usages (not exceeds 99% threshold) + Method method; + try (MockedStatic mockedSystemUtil = mockStatic(SystemUtil.class)) { + mockedSystemUtil.when(SystemUtil::getMemoryUsage).thenReturn(Optional.of(15)); + mockedSystemUtil.when(SystemUtil::getCpuUsage).thenReturn(Optional.of(15)); + mockedSystemUtil.when(SystemUtil::getDiscSpaceUsage).thenReturn(Optional.of(15)); + + method = DefaultSystemInfoService.class.getDeclaredMethod("saveCurrentMonolithSystemInfo"); + method.setAccessible(true); + method.invoke(systemInfoService); + } TimeUnit.SECONDS.sleep(5); assertThat(getMyNotifications(false, 100)).size().isZero(); - loginSysAdmin(); triggerConfig = ResourcesShortageNotificationRuleTriggerConfig.builder() .ramThreshold(0.01f) .cpuThreshold(1f) @@ -871,7 +884,6 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { .build(); rule.setTriggerConfig(triggerConfig); saveNotificationRule(rule); - loginTenantAdmin(); method.invoke(systemInfoService); @@ -944,35 +956,28 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { private DeviceProfile createDeviceProfileWithAlarmRules(String alarmType) { DeviceProfile deviceProfile = createDeviceProfile("For notification rule test"); deviceProfile.setTenantId(tenantId); + deviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); - List alarms = new ArrayList<>(); - DeviceProfileAlarm alarm = new DeviceProfileAlarm(); - alarm.setAlarmType(alarmType); - alarm.setId(alarmType); + CalculatedField alarmCf = new CalculatedField(); + alarmCf.setType(CalculatedFieldType.ALARM); + alarmCf.setEntityId(deviceProfile.getId()); + alarmCf.setName(alarmType); + AlarmCalculatedFieldConfiguration configuration = new AlarmCalculatedFieldConfiguration(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("createAlarm", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + configuration.setArguments(Map.of("createAlarm", argument)); AlarmRule alarmRule = new AlarmRule(); - alarmRule.setAlarmDetails("Details"); - AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setSpec(new SimpleAlarmConditionSpec()); - List condition = new ArrayList<>(); - - AlarmConditionFilter alarmConditionFilter = new AlarmConditionFilter(); - alarmConditionFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.ATTRIBUTE, "bool")); - BooleanFilterPredicate predicate = new BooleanFilterPredicate(); - predicate.setOperation(BooleanFilterPredicate.BooleanOperation.EQUAL); - predicate.setValue(new FilterPredicateValue<>(true)); - - alarmConditionFilter.setPredicate(predicate); - alarmConditionFilter.setValueType(EntityKeyValueType.BOOLEAN); - condition.add(alarmConditionFilter); - alarmCondition.setCondition(condition); - alarmRule.setCondition(alarmCondition); - TreeMap createRules = new TreeMap<>(); - createRules.put(AlarmSeverity.CRITICAL, alarmRule); - alarm.setCreateRules(createRules); - alarms.add(alarm); - - deviceProfile.getProfileData().setAlarms(alarms); - deviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + alarmRule.setAlarmDetails("attribute is ${createAlarm}"); + SimpleAlarmCondition condition = new SimpleAlarmCondition(); + TbelAlarmConditionExpression expression = new TbelAlarmConditionExpression(); + expression.setExpression("return createAlarm == true;"); + condition.setExpression(expression); + alarmRule.setCondition(condition); + configuration.setCreateRules(Map.of( + AlarmSeverity.CRITICAL, alarmRule + )); + alarmCf.setConfiguration(configuration); + saveCalculatedField(alarmCf); return deviceProfile; } diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationTargetApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationTargetApiTest.java index a49b260d9e..2d865542a1 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationTargetApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationTargetApiTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ package org.thingsboard.server.service.notification; import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.commons.lang3.RandomStringUtils; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -30,7 +31,12 @@ import org.thingsboard.server.common.data.notification.targets.NotificationTarge import org.thingsboard.server.common.data.notification.targets.platform.AllUsersFilter; import org.thingsboard.server.common.data.notification.targets.platform.CustomerUsersFilter; import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig; +import org.thingsboard.server.common.data.notification.targets.platform.SystemAdministratorsFilter; import org.thingsboard.server.common.data.notification.targets.platform.UserListFilter; +import org.thingsboard.server.common.data.notification.targets.platform.UsersFilterType; +import org.thingsboard.server.common.data.notification.targets.slack.SlackConversation; +import org.thingsboard.server.common.data.notification.targets.slack.SlackConversationType; +import org.thingsboard.server.common.data.notification.targets.slack.SlackNotificationTargetConfig; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.notification.NotificationTargetDao; @@ -175,6 +181,28 @@ public class NotificationTargetApiTest extends AbstractNotificationApiTest { assertThat(error).containsIgnoringCase("referenced by scheduled notification request"); } + @Test + public void findByTenantIdAndUsersFilterType() throws Exception { + loginSysAdmin(); + NotificationTarget sysAdmins = createNotificationTarget(new SystemAdministratorsFilter()); + + NotificationTarget slack = new NotificationTarget(); + slack.setName(RandomStringUtils.randomNumeric(5)); + SlackNotificationTargetConfig slackConfig = new SlackNotificationTargetConfig(); + SlackConversation slackConversation = new SlackConversation(); + slackConversation.setType(SlackConversationType.DIRECT); + slackConversation.setId("U12345678"); + slackConversation.setName("test"); + slackConfig.setConversation(slackConversation); + slack.setConfiguration(slackConfig); + slack = saveNotificationTarget(slack); + + List sysAdminTargets = notificationTargetDao.findByTenantIdAndUsersFilterType( + TenantId.SYS_TENANT_ID, UsersFilterType.SYSTEM_ADMINISTRATORS + ); + assertThat(sysAdminTargets).containsOnly(sysAdmins); + } + private String saveAndGetError(NotificationTarget notificationTarget, ResultMatcher statusMatcher) throws Exception { return getErrorMessage(save(notificationTarget, statusMatcher)); } diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationTemplateApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationTemplateApiTest.java index 6a2ca31c33..a137702efe 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationTemplateApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationTemplateApiTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/notification/TestNotificationSettingsService.java b/application/src/test/java/org/thingsboard/server/service/notification/TestNotificationSettingsService.java index a0de10de13..78cae099d7 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/TestNotificationSettingsService.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/TestNotificationSettingsService.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java index 40369e231a..8110f6c0e3 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java index 26832f6176..86b5ae2cf8 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackCallbackTest.java b/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackCallbackTest.java index 3d056ddc3d..bc47e7a484 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackCallbackTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackCallbackTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java b/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java index ef1febfa6c..3ea827d330 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java index bcbe52b5c9..228b987cf0 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -595,7 +595,7 @@ public class TbRuleEngineQueueConsumerManagerTest { await().atMost(5, TimeUnit.SECONDS).until(() -> { for (TopicPartitionInfo partition : expectedPartitions) { if (consumers.stream().noneMatch(consumer -> consumer.subscribed && - consumer.pollingStarted && Set.of(partition).equals(consumer.getPartitions()))) { + consumer.pollingStarted && Set.of(partition).equals(consumer.getPartitions()))) { return false; } } @@ -605,7 +605,7 @@ public class TbRuleEngineQueueConsumerManagerTest { await().atMost(5, TimeUnit.SECONDS).until(() -> { return consumers.size() == 1 && consumers.stream() .anyMatch(consumer -> consumer.subscribed && consumer.pollingStarted && - expectedPartitions.equals(consumer.getPartitions())); + expectedPartitions.equals(consumer.getPartitions())); }); } Mockito.reset(ruleEngineConsumerContext.getSubmitStrategyFactory()); @@ -667,8 +667,8 @@ public class TbRuleEngineQueueConsumerManagerTest { return await().atMost(5, TimeUnit.SECONDS) .until(() -> consumers.stream() .filter(consumer -> consumer.getPartitions() != null && - consumer.getPartitions().size() == 1 && - consumer.getPartitions().contains(tpi)) + consumer.getPartitions().size() == 1 && + consumer.getPartitions().contains(tpi)) .findFirst().orElse(null), Objects::nonNull); } @@ -676,9 +676,9 @@ public class TbRuleEngineQueueConsumerManagerTest { return await().atMost(5, TimeUnit.SECONDS) .until(() -> consumers.stream() .filter(consumer -> consumer.getPartitions() != null && - consumer.getPartitions().size() == 1 && - consumer.getPartitions().stream() - .anyMatch(tpi -> tpi.getPartition().get().equals(partition))) + consumer.getPartitions().size() == 1 && + consumer.getPartitions().stream() + .anyMatch(tpi -> tpi.getPartition().get().equals(partition))) .findFirst().orElse(null), Objects::nonNull); } @@ -778,10 +778,6 @@ public class TbRuleEngineQueueConsumerManagerTest { return false; } - public Set getPartitions() { - return partitions; - } - public void setUpTestMsg() { testMsg = TbMsg.newMsg() .type(TbMsgType.POST_TELEMETRY_REQUEST) @@ -790,6 +786,7 @@ public class TbRuleEngineQueueConsumerManagerTest { .data("{}") .build(); } + } } diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java index 1106fad5b6..3b33d91348 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,6 +43,7 @@ import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; import org.thingsboard.server.queue.TbQueueConsumer; import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.consumer.TbQueueConsumerTask.ConsumerKey; import org.thingsboard.server.queue.discovery.QueueKey; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory; @@ -191,6 +192,7 @@ public class TbRuleEngineStrategyTest { queue.setProcessingStrategy(processingStrategy); QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, queue); + ConsumerKey consumerKey = new ConsumerKey(queueKey, null); var consumerManager = TbRuleEngineQueueConsumerManager.create() .ctx(ruleEngineConsumerContext) .queueKey(queueKey) @@ -238,7 +240,7 @@ public class TbRuleEngineStrategyTest { .map(this::toProto) .toList(); - consumerManager.processMsgs(protoMsgs, consumer, queueKey, queue); + consumerManager.processMsgs(protoMsgs, consumer, consumerKey, queue); processingData.forEach(data -> { verify(actorContext, times(data.attempts)).tell(argThat(msg -> diff --git a/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java index f12a8d3c5d..c227ec76aa 100644 --- a/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java +++ b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java b/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java index a146730465..0875bd94ee 100644 --- a/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -60,7 +60,7 @@ import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.dashboard.DashboardService; -import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.service.DaoSqlTest; diff --git a/application/src/test/java/org/thingsboard/server/service/rpc/DefaultTbRuleEngineRpcServiceTest.java b/application/src/test/java/org/thingsboard/server/service/rpc/DefaultTbRuleEngineRpcServiceTest.java index ad5160afda..2cb93ce17c 100644 --- a/application/src/test/java/org/thingsboard/server/service/rpc/DefaultTbRuleEngineRpcServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/rpc/DefaultTbRuleEngineRpcServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/rpc/RpcSubmitStrategyTest.java b/application/src/test/java/org/thingsboard/server/service/rpc/RpcSubmitStrategyTest.java index ee46a38638..1ff64e8c22 100644 --- a/application/src/test/java/org/thingsboard/server/service/rpc/RpcSubmitStrategyTest.java +++ b/application/src/test/java/org/thingsboard/server/service/rpc/RpcSubmitStrategyTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/ruleengine/DefaultRuleEngineCallServiceTest.java b/application/src/test/java/org/thingsboard/server/service/ruleengine/DefaultRuleEngineCallServiceTest.java index 60c71a3837..77e0a50f80 100644 --- a/application/src/test/java/org/thingsboard/server/service/ruleengine/DefaultRuleEngineCallServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/ruleengine/DefaultRuleEngineCallServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/script/AbstractTbelInvokeTest.java b/application/src/test/java/org/thingsboard/server/service/script/AbstractTbelInvokeTest.java index e3383e32a8..34213c3485 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/AbstractTbelInvokeTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/AbstractTbelInvokeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/script/MockJsInvokeService.java b/application/src/test/java/org/thingsboard/server/service/script/MockJsInvokeService.java index facbd6e751..7793761661 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/MockJsInvokeService.java +++ b/application/src/test/java/org/thingsboard/server/service/script/MockJsInvokeService.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/script/NashornJsInvokeServiceTest.java b/application/src/test/java/org/thingsboard/server/service/script/NashornJsInvokeServiceTest.java index b8ab48b38d..01a5f1b6fc 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/NashornJsInvokeServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/NashornJsInvokeServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java b/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java index 36990d9768..1238a4df5a 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java index a7affe6dff..67e4ba8f32 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java index fc66f806d7..54e1a39769 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java index 3eeab8b7d9..950ce4609f 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -60,7 +60,7 @@ public class JwtTokenFactoryTest { public void beforeEach() { jwtSettings = new JwtSettings(); jwtSettings.setTokenIssuer("tb"); - jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString(RandomStringUtils.randomAlphanumeric(64).getBytes(StandardCharsets.UTF_8))); + jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString(RandomStringUtils.secure().nextAlphanumeric(64).getBytes(StandardCharsets.UTF_8))); jwtSettings.setTokenExpirationTime((int) TimeUnit.HOURS.toSeconds(2)); jwtSettings.setRefreshTokenExpTime((int) TimeUnit.DAYS.toSeconds(7)); @@ -89,7 +89,7 @@ public class JwtTokenFactoryTest { AccessJwtToken accessToken = tokenFactory.createAccessJwtToken(securityUser); checkExpirationTime(accessToken, jwtSettings.getTokenExpirationTime()); - SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(accessToken.getToken()); + SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(accessToken.token()); assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId()); assertThat(parsedSecurityUser.getEmail()).isEqualTo(securityUser.getEmail()); assertThat(parsedSecurityUser.getUserPrincipal()).matches(userPrincipal -> { @@ -112,7 +112,7 @@ public class JwtTokenFactoryTest { JwtToken refreshToken = tokenFactory.createRefreshToken(securityUser); checkExpirationTime(refreshToken, jwtSettings.getRefreshTokenExpTime()); - SecurityUser parsedSecurityUser = tokenFactory.parseRefreshToken(refreshToken.getToken()); + SecurityUser parsedSecurityUser = tokenFactory.parseRefreshToken(refreshToken.token()); assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId()); assertThat(parsedSecurityUser.getUserPrincipal()).matches(userPrincipal -> { return userPrincipal.getType().equals(securityUser.getUserPrincipal().getType()) @@ -125,10 +125,10 @@ public class JwtTokenFactoryTest { public void testCreateAndParsePreVerificationJwtToken() { SecurityUser securityUser = createSecurityUser(); int tokenLifetime = (int) TimeUnit.MINUTES.toSeconds(30); - JwtToken preVerificationToken = tokenFactory.createPreVerificationToken(securityUser, tokenLifetime); + JwtToken preVerificationToken = tokenFactory.createMfaToken(securityUser, Authority.PRE_VERIFICATION_TOKEN, tokenLifetime); checkExpirationTime(preVerificationToken, tokenLifetime); - SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(preVerificationToken.getToken()); + SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(preVerificationToken.token()); assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId()); assertThat(parsedSecurityUser.getAuthority()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN); assertThat(parsedSecurityUser.getTenantId()).isEqualTo(securityUser.getTenantId()); @@ -144,7 +144,7 @@ public class JwtTokenFactoryTest { SecurityUser securityUser = createSecurityUser(); String sessionId = securityUser.getSessionId(); - String accessToken = tokenFactory.createAccessJwtToken(securityUser).getToken(); + String accessToken = tokenFactory.createAccessJwtToken(securityUser).token(); securityUser = tokenFactory.parseAccessJwtToken(accessToken); assertThat(securityUser.getSessionId()).isNotNull().isEqualTo(sessionId); @@ -158,7 +158,7 @@ public class JwtTokenFactoryTest { securityUser.setId(new UserId(UUID.randomUUID())); securityUser.setEmail("tenant@thingsboard.org"); securityUser.setAuthority(Authority.TENANT_ADMIN); - securityUser.setTenantId(new TenantId(UUID.randomUUID())); + securityUser.setTenantId(TenantId.fromUUID(UUID.randomUUID())); securityUser.setEnabled(true); securityUser.setFirstName("A"); securityUser.setLastName("B"); @@ -179,7 +179,7 @@ public class JwtTokenFactoryTest { } private void checkExpirationTime(JwtToken jwtToken, int tokenLifetime) { - Claims claims = tokenFactory.parseTokenClaims(jwtToken.getToken()).getPayload(); + Claims claims = tokenFactory.parseTokenClaims(jwtToken.token()).getPayload(); assertThat(claims.getExpiration()).matches(actualExpirationTime -> { Calendar expirationTime = Calendar.getInstance(); expirationTime.setTime(new Date()); diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java index aebd98a5bf..e54e36c33f 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/TokenOutdatingTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,15 +30,14 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit4.SpringRunner; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.UserAuthDetails; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.security.event.UserCredentialsInvalidationEvent; import org.thingsboard.server.common.data.security.event.UserSessionInvalidationEvent; import org.thingsboard.server.common.data.security.model.JwtToken; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.service.DaoSqlTest; -import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenAuthenticationProvider; import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; @@ -46,6 +45,7 @@ import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; +import org.thingsboard.server.service.user.cache.UserAuthDetailsCache; import java.util.UUID; @@ -91,20 +91,16 @@ public class TokenOutdatingTest { UserId userId = new UserId(UUID.randomUUID()); securityUser = createMockSecurityUser(userId); - UserService userService = mock(UserService.class); + UserAuthDetailsCache userAuthDetailsCache = mock(UserAuthDetailsCache.class); User user = new User(); user.setId(userId); user.setAuthority(Authority.TENANT_ADMIN); user.setEmail("email"); - when(userService.findUserById(any(), eq(userId))).thenReturn(user); - - UserCredentials userCredentials = new UserCredentials(); - userCredentials.setEnabled(true); - when(userService.findUserCredentialsByUserId(any(), eq(userId))).thenReturn(userCredentials); + when(userAuthDetailsCache.getUserAuthDetails(any(), eq(userId))).thenReturn(new UserAuthDetails(user, true)); accessTokenAuthenticationProvider = new JwtAuthenticationProvider(tokenFactory, tokenOutdatingService); - refreshTokenAuthenticationProvider = new RefreshTokenAuthenticationProvider(tokenFactory, userService, mock(CustomerService.class), tokenOutdatingService); + refreshTokenAuthenticationProvider = new RefreshTokenAuthenticationProvider(tokenFactory, userAuthDetailsCache, mock(CustomerService.class), tokenOutdatingService); } @Test @@ -114,12 +110,12 @@ public class TokenOutdatingTest { // Token outdatage time is rounded to 1 sec. Need to wait before outdating so that outdatage time is strictly after token issue time SECONDS.sleep(1); eventPublisher.publishEvent(new UserCredentialsInvalidationEvent(securityUser.getId())); - assertTrue(tokenOutdatingService.isOutdated(jwtToken.getToken(), securityUser.getId())); + assertTrue(tokenOutdatingService.isOutdated(jwtToken.token(), securityUser.getId())); SECONDS.sleep(1); JwtToken newJwtToken = tokenFactory.createAccessJwtToken(securityUser); - assertFalse(tokenOutdatingService.isOutdated(newJwtToken.getToken(), securityUser.getId())); + assertFalse(tokenOutdatingService.isOutdated(newJwtToken.token(), securityUser.getId())); } @Test @@ -229,7 +225,7 @@ public class TokenOutdatingTest { private RawAccessJwtToken getRawJwtToken(JwtToken token) { - return new RawAccessJwtToken(token.getToken()); + return new RawAccessJwtToken(token.token()); } private SecurityUser createMockSecurityUser(UserId userId) { @@ -241,4 +237,5 @@ public class TokenOutdatingTest { securityUser.setSessionId(UUID.randomUUID().toString()); return securityUser; } + } diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CookieUtilsTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CookieUtilsTest.java index a94d87dd92..e00dc23097 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CookieUtilsTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/CookieUtilsTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java index 79b1c9aa69..7e4c2645c7 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandlerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java new file mode 100644 index 0000000000..e9c36b1ebd --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProviderTest.java @@ -0,0 +1,174 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.security.auth.pat; + +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; + +@DaoSqlTest +public class ApiKeyAuthenticationProviderTest extends AbstractControllerTest { + + ApiKey savedApiKey; + + @Before + public void setUp() throws Exception { + loginTenantAdmin(); + + ApiKeyInfo apiKeyInfo = constructApiKeyInfo(); + savedApiKey = doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + setApiKey(savedApiKey.getValue()); + } + + @After + public void cleanUp() throws Exception { + resetApiKey(); + doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk()); + } + + @Test + public void testSaveEdgeWithApiKey() throws Exception { + Edge edge = constructEdge("My edge", "default"); + + Mockito.reset(tbClusterService, auditLogService); + + Edge savedEdge = doPostWithApiKey("/api/edge", edge, Edge.class); + + Assert.assertNotNull(savedEdge); + Assert.assertNotNull(savedEdge.getId()); + Assert.assertTrue(savedEdge.getCreatedTime() > 0); + Assert.assertEquals(tenantId, savedEdge.getTenantId()); + Assert.assertNotNull(savedEdge.getCustomerId()); + Assert.assertEquals(NULL_UUID, savedEdge.getCustomerId().getId()); + Assert.assertEquals(edge.getName(), savedEdge.getName()); + + testNotifyEdgeStateChangeEventManyTimeMsgToEdgeServiceNever(savedEdge, savedEdge.getId(), savedEdge.getId(), + tenantId, tenantAdminUser.getCustomerId(), tenantAdminUser.getId(), tenantAdminUser.getEmail(), + ActionType.ADDED, 2); + + savedEdge.setName("My new edge"); + doPostWithApiKey("/api/edge", savedEdge, Edge.class); + + Edge foundEdge = doGetWithApiKey("/api/edge/" + savedEdge.getId().getId().toString(), Edge.class); + Assert.assertEquals(foundEdge.getName(), savedEdge.getName()); + + testNotifyEdgeStateChangeEventManyTimeMsgToEdgeServiceNever(foundEdge, foundEdge.getId(), foundEdge.getId(), + tenantId, tenantAdminUser.getCustomerId(), tenantAdminUser.getId(), tenantAdminUser.getEmail(), + ActionType.UPDATED, 1); + + doDeleteWithApiKey("/api/edge/" + savedEdge.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testUnauthorizedWhenKeyDisabled() throws Exception { + ApiKeyInfo disabledApiKeyInfo = doPut("/api/apiKey/" + savedApiKey.getId().getId() + "/enabled/false", Boolean.FALSE, ApiKeyInfo.class); + Assert.assertFalse(disabledApiKeyInfo.isEnabled()); + doGetWithApiKey("/api/admin/featuresInfo").andExpect(status().isUnauthorized()); + } + + @Test + public void testUnauthorizedWhenKeyExpired() throws Exception { + ApiKeyInfo apiKeyInfo = constructApiKeyInfo(); + apiKeyInfo.setExpirationTime(System.currentTimeMillis() - 1000); + ApiKey savedApiKeyWithBad = doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + setApiKey(savedApiKeyWithBad.getValue()); + doPost("/api/apiKey", savedApiKey, ApiKeyInfo.class); + doGetWithApiKey("/api/admin/featuresInfo").andExpect(status().isUnauthorized()); + } + + @Test + public void testUnauthorizedWhenUserCredentialsDisabled() throws Exception { + User newUser = new User(); + newUser.setAuthority(Authority.TENANT_ADMIN); + newUser.setTenantId(tenantId); + newUser.setEmail("testUser" + RandomStringUtils.secure().nextAlphanumeric(10) + "@thingsboard.org"); + newUser.setFirstName("Test"); + newUser.setLastName("User"); + User savedUser = createUser(newUser, "testPassword1"); + + ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); + apiKeyInfo.setDescription("Test API key for user credentials test"); + apiKeyInfo.setEnabled(true); + apiKeyInfo.setUserId(savedUser.getId()); + ApiKey testApiKey = doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + setApiKey(testApiKey.getValue()); + + doGetWithApiKey("/api/admin/repositorySettings/exists").andExpect(status().isOk()); + + doPost("/api/user/" + savedUser.getId().getId() + "/userCredentialsEnabled?userCredentialsEnabled=false").andExpect(status().isOk()); + + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> doGetWithApiKey("/api/admin/repositorySettings/exists").andExpect(status().isUnauthorized())); + + resetApiKey(); + doDelete("/api/apiKey/" + testApiKey.getId()).andExpect(status().isOk()); + loginSysAdmin(); + doDelete("/api/user/" + savedUser.getId().getId()).andExpect(status().isOk()); + } + + @Test + public void testUnauthorizedWhenUserDeleted() throws Exception { + User newUser = new User(); + newUser.setAuthority(Authority.TENANT_ADMIN); + newUser.setTenantId(tenantId); + newUser.setEmail("testUser" + RandomStringUtils.secure().nextAlphanumeric(10) + "@thingsboard.org"); + newUser.setFirstName("Test"); + newUser.setLastName("User"); + User savedUser = createUser(newUser, "testPassword1"); + + ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); + apiKeyInfo.setDescription("Test API key for user deletion test"); + apiKeyInfo.setEnabled(true); + apiKeyInfo.setUserId(savedUser.getId()); + ApiKey testApiKey = doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + setApiKey(testApiKey.getValue()); + + doGetWithApiKey("/api/admin/repositorySettings/exists").andExpect(status().isOk()); + + loginSysAdmin(); + doDelete("/api/user/" + savedUser.getId().getId()).andExpect(status().isOk()); + + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> doGetWithApiKey("/api/admin/repositorySettings/exists").andExpect(status().isUnauthorized())); + } + + private ApiKeyInfo constructApiKeyInfo() { + ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); + apiKeyInfo.setDescription("New API key description"); + apiKeyInfo.setEnabled(true); + apiKeyInfo.setUserId(tenantAdminUserId); + return apiKeyInfo; + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java b/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java index e31250414b..445be6363a 100644 --- a/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java b/application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java index 4802b709b2..64145c24f8 100644 --- a/application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/sql/SequentialTimeseriesPersistenceTest.java b/application/src/test/java/org/thingsboard/server/service/sql/SequentialTimeseriesPersistenceTest.java index 46db5b8e5b..c767319e53 100644 --- a/application/src/test/java/org/thingsboard/server/service/sql/SequentialTimeseriesPersistenceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sql/SequentialTimeseriesPersistenceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateManagerTest.java b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateManagerTest.java index 6cf0ab9053..1c557bf5a5 100644 --- a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateManagerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateManagerTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java index 5bf87137ff..a091e0180f 100644 --- a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/stats/DevicesStatisticsTest.java b/application/src/test/java/org/thingsboard/server/service/stats/DevicesStatisticsTest.java index 0d65728a5f..31283a1165 100644 --- a/application/src/test/java/org/thingsboard/server/service/stats/DevicesStatisticsTest.java +++ b/application/src/test/java/org/thingsboard/server/service/stats/DevicesStatisticsTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionServiceTest.java b/application/src/test/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionServiceTest.java index 3bb470830b..2eb7934792 100644 --- a/application/src/test/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtxTest.java b/application/src/test/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtxTest.java index 1f0da1a30c..1d0e691c74 100644 --- a/application/src/test/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtxTest.java +++ b/application/src/test/java/org/thingsboard/server/service/subscription/TbEntityDataSubCtxTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/subscription/TbEntityLocalSubsInfoTest.java b/application/src/test/java/org/thingsboard/server/service/subscription/TbEntityLocalSubsInfoTest.java index 5935632195..ba1a89eb38 100644 --- a/application/src/test/java/org/thingsboard/server/service/subscription/TbEntityLocalSubsInfoTest.java +++ b/application/src/test/java/org/thingsboard/server/service/subscription/TbEntityLocalSubsInfoTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java index e70a0cd37c..9630dacdd4 100644 --- a/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -48,10 +48,9 @@ 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.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; import org.thingsboard.server.common.data.device.data.DeviceData; import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; @@ -627,9 +626,8 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { config.setExpression("T - (100 - H) / 5"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("output"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); 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..0305d031b8 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 @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; import com.google.common.collect.Streams; +import org.apache.commons.lang3.tuple.Pair; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -45,17 +46,21 @@ import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.alarm.rule.AlarmRule; +import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.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.AlarmCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.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.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; import org.thingsboard.server.common.data.debug.DebugSettings; import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; import org.thingsboard.server.common.data.device.data.DeviceData; @@ -244,7 +249,7 @@ public class VersionControlTest extends AbstractControllerTest { DeviceProfile deviceProfile = createDeviceProfile(null, null, "Device profile v1.0"); OtaPackage firmware = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.FIRMWARE); OtaPackage software = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.SOFTWARE); - Device device = createDevice(null, deviceProfile.getId(), "Device v1.0", "test1", newDevice -> { + Device device = createDevice(deviceProfile.getId(), "Device v1.0", "test1", newDevice -> { newDevice.setFirmwareId(firmware.getId()); newDevice.setSoftwareId(software.getId()); }); @@ -267,7 +272,7 @@ public class VersionControlTest extends AbstractControllerTest { createVersion("profiles", EntityType.DEVICE_PROFILE); OtaPackage firmware = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.FIRMWARE); OtaPackage software = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.SOFTWARE); - Device device = createDevice(null, deviceProfile.getId(), "Device of tenant 1", "test1", newDevice -> { + Device device = createDevice(deviceProfile.getId(), "Device of tenant 1", "test1", newDevice -> { newDevice.setFirmwareId(firmware.getId()); newDevice.setSoftwareId(software.getId()); }); @@ -307,6 +312,54 @@ public class VersionControlTest extends AbstractControllerTest { checkImportedOtaPackageData(software, importedSoftwareOta); } + @Test + public void testDeviceVc_withAlarmRules_betweenTenants() throws Exception { + DeviceProfile deviceProfile = createDeviceProfile(null, null, "Device profile of tenant 1"); + Dashboard dashboard = createDashboard(null, "Mobile dashboard"); + createAlarmRule(deviceProfile.getId(), "Profile alarm rule", dashboard.getId()); + Device device = createDevice(deviceProfile.getId(), "Device of tenant 1", "test1"); + createAlarmRule(device.getId(), "Device alarm rule", dashboard.getId()); + String version = createVersion("devices, profiles and dashboards", EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.DASHBOARD); + + loginTenant2(); + Map result = loadVersion(version, config -> { + config.setLoadCredentials(false); + }, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.DASHBOARD); + assertThat(result.get(EntityType.DEVICE).getCreated()).isEqualTo(1); + assertThat(result.get(EntityType.DEVICE_PROFILE).getCreated()).isEqualTo(1); + assertThat(result.get(EntityType.DASHBOARD).getCreated()).isEqualTo(1); + + Device importedDevice = findDevice(device.getName()); + checkImportedEntity(tenantId1, device, tenantId2, importedDevice); + checkImportedDeviceData(device, importedDevice); + + DeviceProfile importedDeviceProfile = findDeviceProfile(deviceProfile.getName()); + checkImportedEntity(tenantId1, deviceProfile, tenantId2, importedDeviceProfile); + checkImportedDeviceProfileData(deviceProfile, importedDeviceProfile); + assertThat(importedDevice.getDeviceProfileId()).isEqualTo(importedDeviceProfile.getId()); + + Dashboard importedDashboard = findDashboard(dashboard.getName()); + checkImportedEntity(tenantId1, dashboard, tenantId2, importedDashboard); + checkImportedDashboardData(dashboard, importedDashboard); + + getCalculatedFields(CalculatedFieldType.ALARM, EntityType.DEVICE_PROFILE, + List.of(importedDeviceProfile.getUuidId()), null).forEach(alarmRuleCf -> { + assertThat(alarmRuleCf.getName()).isEqualTo("Profile alarm rule"); + AlarmCalculatedFieldConfiguration config = (AlarmCalculatedFieldConfiguration) alarmRuleCf.getConfiguration(); + config.getAllRules().map(Pair::getValue).forEach(alarmRule -> { + assertThat(alarmRule.getDashboardId()).isEqualTo(importedDashboard.getId()); + }); + }); + getCalculatedFields(CalculatedFieldType.ALARM, EntityType.DEVICE_PROFILE, + List.of(importedDevice.getUuidId()), null).forEach(alarmRuleCf -> { + assertThat(alarmRuleCf.getName()).isEqualTo("Device alarm rule"); + AlarmCalculatedFieldConfiguration config = (AlarmCalculatedFieldConfiguration) alarmRuleCf.getConfiguration(); + config.getAllRules().map(Pair::getValue).forEach(alarmRule -> { + assertThat(alarmRule.getDashboardId()).isEqualTo(importedDashboard.getId()); + }); + }); + } + @Test public void testDashboardVc_betweenTenants() throws Exception { Dashboard dashboard = createDashboard(null, "Dashboard of tenant 1"); @@ -369,42 +422,42 @@ public class VersionControlTest extends AbstractControllerTest { String aliasId = "23c4185d-1497-9457-30b2-6d91e69a5b2c"; String unknownUuid = "ea0dc8b0-3d85-11ed-9200-77fc04fa14fa"; String entityAliases = "{\n" + - "\"" + aliasId + "\": {\n" + - "\"alias\": \"assets\",\n" + - "\"filter\": {\n" + - " \"entityList\": [\n" + - " \"" + asset1.getId() + "\",\n" + - " \"" + asset2.getId() + "\",\n" + - " \"" + tenantId1.getId() + "\",\n" + - " \"" + existingDeviceProfile.getId() + "\",\n" + - " \"" + unknownUuid + "\"\n" + - " ],\n" + - " \"id\":\"" + asset1.getId() + "\",\n" + - " \"resolveMultiple\": true\n" + - "},\n" + - "\"id\": \"" + aliasId + "\"\n" + - "}\n" + - "}"; + "\"" + aliasId + "\": {\n" + + "\"alias\": \"assets\",\n" + + "\"filter\": {\n" + + " \"entityList\": [\n" + + " \"" + asset1.getId() + "\",\n" + + " \"" + asset2.getId() + "\",\n" + + " \"" + tenantId1.getId() + "\",\n" + + " \"" + existingDeviceProfile.getId() + "\",\n" + + " \"" + unknownUuid + "\"\n" + + " ],\n" + + " \"id\":\"" + asset1.getId() + "\",\n" + + " \"resolveMultiple\": true\n" + + "},\n" + + "\"id\": \"" + aliasId + "\"\n" + + "}\n" + + "}"; String widgetId = "ea8f34a0-264a-f11f-cde3-05201bb4ff4b"; String actionId = "4a8e6efa-3e68-fa59-7feb-d83366130cae"; String widgets = "{\n" + - " \"" + widgetId + "\": {\n" + - " \"config\": {\n" + - " \"actions\": {\n" + - " \"rowClick\": [\n" + - " {\n" + - " \"name\": \"go to dashboard\",\n" + - " \"targetDashboardId\": \"" + otherDashboard.getId() + "\",\n" + - " \"id\": \"" + actionId + "\"\n" + - " }\n" + - " ]\n" + - " }\n" + - " },\n" + - " \"row\": 0,\n" + - " \"col\": 0,\n" + - " \"id\": \"" + widgetId + "\"\n" + - " }\n" + - "}"; + " \"" + widgetId + "\": {\n" + + " \"config\": {\n" + + " \"actions\": {\n" + + " \"rowClick\": [\n" + + " {\n" + + " \"name\": \"go to dashboard\",\n" + + " \"targetDashboardId\": \"" + otherDashboard.getId() + "\",\n" + + " \"id\": \"" + actionId + "\"\n" + + " }\n" + + " ]\n" + + " }\n" + + " },\n" + + " \"row\": 0,\n" + + " \"col\": 0,\n" + + " \"id\": \"" + widgetId + "\"\n" + + " }\n" + + "}"; ObjectNode dashboardConfiguration = JacksonUtil.newObjectNode(); dashboardConfiguration.set("entityAliases", JacksonUtil.toJsonNode(entityAliases)); @@ -500,11 +553,12 @@ public class VersionControlTest extends AbstractControllerTest { generatorNodeConfig.setMsgCount(1); generatorNodeConfig.setScriptLang(ScriptLanguage.JS); UUID someUuid = UUID.randomUUID(); - generatorNodeConfig.setJsScript("var msg = { temp: 42, humidity: 77 };\n" + - "var metadata = { data: 40 };\n" + - "var msgType = \"POST_TELEMETRY_REQUEST\";\n" + - "var someUuid = \"" + someUuid + "\";\n" + - "return { msg: msg, metadata: metadata, msgType: msgType };"); + generatorNodeConfig.setJsScript(""" + var msg = { temp: 42, humidity: 77 }; + var metadata = { data: 40 }; + var msgType = "POST_TELEMETRY_REQUEST"; + var someUuid = "%s"; + return { msg: msg, metadata: metadata, msgType: msgType };""".formatted(someUuid)); generatorNode.setConfiguration(JacksonUtil.valueToTree(generatorNodeConfig)); nodes.add(generatorNode); metaData.setNodes(nodes); @@ -528,7 +582,7 @@ public class VersionControlTest extends AbstractControllerTest { @Test public void testVcWithRelations_betweenTenants() throws Exception { Asset asset = createAsset(null, null, "Asset 1"); - Device device = createDevice(null, null, "Device 1", "test1"); + Device device = createDevice("Device 1", "test1"); EntityRelation relation = createRelation(asset.getId(), device.getId()); String versionId = createVersion("assets and devices", EntityType.ASSET, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE); @@ -554,11 +608,11 @@ public class VersionControlTest extends AbstractControllerTest { @Test public void testVcWithRelations_sameTenant() throws Exception { Asset asset = createAsset(null, null, "Asset 1"); - Device device1 = createDevice(null, null, "Device 1", "test1"); + Device device1 = createDevice("Device 1", "test1"); EntityRelation relation1 = createRelation(device1.getId(), asset.getId()); String versionId = createVersion("assets", EntityType.ASSET); - Device device2 = createDevice(null, null, "Device 2", "test2"); + Device device2 = createDevice("Device 2", "test2"); EntityRelation relation2 = createRelation(device2.getId(), asset.getId()); List relations = findRelationsByTo(asset.getId()); assertThat(relations).contains(relation1, relation2); @@ -591,7 +645,7 @@ public class VersionControlTest extends AbstractControllerTest { @Test public void testVcWithCalculatedFields_betweenTenants() throws Exception { Asset asset = createAsset(null, null, "Asset 1"); - Device device = createDevice(null, null, "Device 1", "test1"); + Device device = createDevice("Device 1", "test1"); CalculatedField calculatedField = createCalculatedField("CalculatedField1", device.getId(), asset.getId()); String versionId = createVersion("calculated fields of asset and device", EntityType.ASSET, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE); @@ -617,7 +671,7 @@ public class VersionControlTest extends AbstractControllerTest { @Test public void testVcWithReferencedCalculatedFields_betweenTenants() throws Exception { Asset asset = createAsset(null, null, "Asset 1"); - Device device = createDevice(null, null, "Device 1", "test1"); + Device device = createDevice("Device 1", "test1"); CalculatedField deviceCalculatedField = createCalculatedField("CalculatedField1", device.getId(), asset.getId()); CalculatedField assetCalculatedField = createCalculatedField("CalculatedField2", asset.getId(), device.getId()); String versionId = createVersion("calculated fields of asset and device", EntityType.ASSET, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE); @@ -638,7 +692,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 +703,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()); }); } @@ -907,9 +965,8 @@ public class VersionControlTest extends AbstractControllerTest { login(tenantAdmin2.getEmail(), tenantAdmin2.getEmail()); } - private Device createDevice(CustomerId customerId, DeviceProfileId deviceProfileId, String name, String accessToken, Consumer... modifiers) { + private Device createDevice(DeviceProfileId deviceProfileId, String name, String accessToken, Consumer... modifiers) { Device device = new Device(); - device.setCustomerId(customerId); device.setName(name); device.setLabel("lbl"); device.setDeviceProfileId(deviceProfileId); @@ -1016,20 +1073,21 @@ public class VersionControlTest extends AbstractControllerTest { protected Dashboard createDashboard(CustomerId customerId, String name, AssetId assetForEntityAlias) { Dashboard dashboard = createDashboard(customerId, name); - String entityAliases = "{\n" + - "\t\"23c4185d-1497-9457-30b2-6d91e69a5b2c\": {\n" + - "\t\t\"alias\": \"assets\",\n" + - "\t\t\"filter\": {\n" + - "\t\t\t\"entityList\": [\n" + - "\t\t\t\t\"" + assetForEntityAlias.getId().toString() + "\"\n" + - "\t\t\t],\n" + - "\t\t\t\"entityType\": \"ASSET\",\n" + - "\t\t\t\"resolveMultiple\": true,\n" + - "\t\t\t\"type\": \"entityList\"\n" + - "\t\t},\n" + - "\t\t\"id\": \"23c4185d-1497-9457-30b2-6d91e69a5b2c\"\n" + - "\t}\n" + - "}"; + String entityAliases = """ + { + "23c4185d-1497-9457-30b2-6d91e69a5b2c": { + "alias": "assets", + "filter": { + "entityList": [ + "%s" + ], + "entityType": "ASSET", + "resolveMultiple": true, + "type": "entityList" + }, + "id": "23c4185d-1497-9457-30b2-6d91e69a5b2c" + } + }""".formatted(assetForEntityAlias.getId().toString()); ObjectNode dashboardConfiguration = JacksonUtil.newObjectNode(); dashboardConfiguration.set("entityAliases", JacksonUtil.toJsonNode(entityAliases)); dashboardConfiguration.set("description", new TextNode("hallo")); @@ -1132,6 +1190,33 @@ public class VersionControlTest extends AbstractControllerTest { return doPost("/api/calculatedField", calculatedField, CalculatedField.class); } + private CalculatedField createAlarmRule(EntityId entityId, String alarmType, DashboardId mobileDashboardId) { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(entityId); + calculatedField.setName(alarmType); + calculatedField.setType(CalculatedFieldType.ALARM); + AlarmCalculatedFieldConfiguration configuration = new AlarmCalculatedFieldConfiguration(); + configuration.setArguments(arguments); + SimpleAlarmCondition createCondition = new SimpleAlarmCondition(); + createCondition.setExpression(new TbelAlarmConditionExpression("return temperature >= 50;")); + configuration.setCreateRules(Map.of( + AlarmSeverity.CRITICAL, new AlarmRule(createCondition, "", mobileDashboardId) + )); + SimpleAlarmCondition clearCondition = new SimpleAlarmCondition(); + clearCondition.setExpression(new TbelAlarmConditionExpression("return temperature < 50;")); + configuration.setClearRule(new AlarmRule(clearCondition, "", mobileDashboardId)); + calculatedField.setConfiguration(configuration); + calculatedField.setDebugSettings(DebugSettings.all()); + return saveCalculatedField(calculatedField); + } + private CalculatedFieldConfiguration getCalculatedFieldConfig(EntityId referencedEntityId) { SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); @@ -1144,9 +1229,8 @@ public class VersionControlTest extends AbstractControllerTest { config.setExpression("T - (100 - H) / 5"); - Output output = new Output(); + TimeSeriesOutput output = new TimeSeriesOutput(); output.setName("output"); - output.setType(OutputType.TIME_SERIES); config.setOutput(output); diff --git a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java index 153228a865..f278153ed8 100644 --- a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -162,7 +162,7 @@ class DefaultTelemetrySubscriptionServiceTest { apiUsageState.setDbStorageState(ApiUsageStateValue.ENABLED); lenient().when(apiUsageStateService.getApiUsageState(tenantId)).thenReturn(apiUsageState); - lenient().when(partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId)).thenReturn(tpi); + lenient().when(partitionService.resolve(eq(ServiceType.TB_CORE), eq(tenantId), any())).thenReturn(tpi); lenient().when(tsService.save(tenantId, entityId, sampleTimeseries, sampleTtl)).thenReturn(immediateFuture(TimeseriesSaveResult.of(sampleTimeseries.size(), listOfNNumbers(sampleTimeseries.size())))); lenient().when(tsService.saveWithoutLatest(tenantId, entityId, sampleTimeseries, sampleTtl)).thenReturn(immediateFuture(TimeseriesSaveResult.of(sampleTimeseries.size(), null))); @@ -310,8 +310,6 @@ class DefaultTelemetrySubscriptionServiceTest { given(tbEntityViewService.findEntityViewsByTenantIdAndEntityIdAsync(tenantId, entityId)).willReturn(immediateFuture(List.of(entityView))); // mock that save latest call for entity view is successful given(tsService.saveLatest(tenantId, entityView.getId(), sampleTimeseries)).willReturn(immediateFuture(TimeseriesSaveResult.of(sampleTimeseries.size(), listOfNNumbers(sampleTimeseries.size())))); - // mock TPI for entity view - given(partitionService.resolve(ServiceType.TB_CORE, tenantId, entityView.getId())).willReturn(tpi); var request = TimeseriesSaveRequest.builder() .tenantId(tenantId) @@ -373,8 +371,6 @@ class DefaultTelemetrySubscriptionServiceTest { lenient().when(tbEntityViewService.findEntityViewsByTenantIdAndEntityIdAsync(tenantId, entityId)).thenReturn(immediateFuture(List.of(entityView))); // mock that save latest call for entity view is successful lenient().when(tsService.saveLatest(tenantId, entityView.getId(), sampleTimeseries)).thenReturn(immediateFuture(TimeseriesSaveResult.of(sampleTimeseries.size(), listOfNNumbers(sampleTimeseries.size())))); - // mock TPI for entity view - lenient().when(partitionService.resolve(ServiceType.TB_CORE, tenantId, entityView.getId())).thenReturn(tpi); var request = TimeseriesSaveRequest.builder() .tenantId(tenantId) diff --git a/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java index 9ed6a19f1d..afb77945ab 100644 --- a/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/ttl/AlarmsCleanUpServiceTest.java b/application/src/test/java/org/thingsboard/server/service/ttl/AlarmsCleanUpServiceTest.java index a1ae1d48ce..fcb948239f 100644 --- a/application/src/test/java/org/thingsboard/server/service/ttl/AlarmsCleanUpServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/ttl/AlarmsCleanUpServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/service/ttl/EventsCleanUpServiceTest.java b/application/src/test/java/org/thingsboard/server/service/ttl/EventsCleanUpServiceTest.java index 778e19ec9b..8116b91bde 100644 --- a/application/src/test/java/org/thingsboard/server/service/ttl/EventsCleanUpServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/ttl/EventsCleanUpServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java index 65633c6bd9..dc28913851 100644 --- a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java +++ b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/system/BaseRestApiLimitsTest.java b/application/src/test/java/org/thingsboard/server/system/BaseRestApiLimitsTest.java index fea9d5086c..6d1d1fdb9c 100644 --- a/application/src/test/java/org/thingsboard/server/system/BaseRestApiLimitsTest.java +++ b/application/src/test/java/org/thingsboard/server/system/BaseRestApiLimitsTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java b/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java index f976156a86..54ddbd1e04 100644 --- a/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java +++ b/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java index 3c65f19d65..67e5e946c8 100644 --- a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java +++ b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/system/sql/DeviceApiSqlTest.java b/application/src/test/java/org/thingsboard/server/system/sql/DeviceApiSqlTest.java index 27e5237fd9..97dcdb8a17 100644 --- a/application/src/test/java/org/thingsboard/server/system/sql/DeviceApiSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/system/sql/DeviceApiSqlTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/system/sql/RestApiLimitsSqlTest.java b/application/src/test/java/org/thingsboard/server/system/sql/RestApiLimitsSqlTest.java index 932fb32328..d714df75c2 100644 --- a/application/src/test/java/org/thingsboard/server/system/sql/RestApiLimitsSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/system/sql/RestApiLimitsSqlTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java index e9255d5930..0badcd2656 100644 --- a/application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/TransportNoSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/transport/TransportNoSqlTestSuite.java index 28fdfafdf9..f9354c64e7 100644 --- a/application/src/test/java/org/thingsboard/server/transport/TransportNoSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/transport/TransportNoSqlTestSuite.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/AbstractCoapIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/AbstractCoapIntegrationTest.java index 3193268bb2..7240ab0afa 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/AbstractCoapIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/AbstractCoapIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/CoapTestCallback.java b/application/src/test/java/org/thingsboard/server/transport/coap/CoapTestCallback.java index d2c6f4f82e..8f36fef5d4 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/CoapTestCallback.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/CoapTestCallback.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/CoapTestClient.java b/application/src/test/java/org/thingsboard/server/transport/coap/CoapTestClient.java index fe4d32a629..ce72d68fc6 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/CoapTestClient.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/CoapTestClient.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/CoapTestConfigProperties.java b/application/src/test/java/org/thingsboard/server/transport/coap/CoapTestConfigProperties.java index c6b22cef8d..85f1968e25 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/CoapTestConfigProperties.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/CoapTestConfigProperties.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java index e053ad9c9e..de5423abf1 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestIntegrationTest.java index 25f35561bc..8ba5cd140a 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestJsonIntegrationTest.java index bb07ce3f13..e9f75574a4 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestJsonIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestProtoIntegrationTest.java index 1d3859fbe5..277263a492 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestProtoIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesIntegrationTest.java index 5b5b96045f..3ed8936a80 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesJsonIntegrationTest.java index a7d15d8539..5b3acd9211 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesJsonIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesProtoIntegrationTest.java index 46dedb43f4..2be1e98dc2 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/updates/CoapAttributesUpdatesProtoIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimDeviceTest.java index 2a8d0c6d1f..145d9c9f51 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimDeviceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimJsonDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimJsonDeviceTest.java index b68a97dd05..d09325f5bd 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimJsonDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimJsonDeviceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimProtoDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimProtoDeviceTest.java index 10e3404cc5..f1fc04f1a6 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimProtoDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/claim/CoapClaimProtoDeviceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/client/CoapClientIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/client/CoapClientIntegrationTest.java index 3845cc0c3c..12b2b677d4 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/client/CoapClientIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/client/CoapClientIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionJsonDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionJsonDeviceTest.java index e001e2380d..e04317d2ea 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionJsonDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionJsonDeviceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionProtoDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionProtoDeviceTest.java index 9ad9a9958b..25b74b0502 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionProtoDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/provision/CoapProvisionProtoDeviceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcIntegrationTest.java index d42546a928..596a9230c9 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/rpc/CoapServerSideRpcDefaultIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/rpc/CoapServerSideRpcDefaultIntegrationTest.java index d1d7fbaf97..6474ff6a26 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/rpc/CoapServerSideRpcDefaultIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/rpc/CoapServerSideRpcDefaultIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/rpc/CoapServerSideRpcJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/rpc/CoapServerSideRpcJsonIntegrationTest.java index bef95d6074..9d5cb7c187 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/rpc/CoapServerSideRpcJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/rpc/CoapServerSideRpcJsonIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/rpc/CoapServerSideRpcProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/rpc/CoapServerSideRpcProtoIntegrationTest.java index a8b430ea01..5024fa2068 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/rpc/CoapServerSideRpcProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/rpc/CoapServerSideRpcProtoIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/security/AbstractCoapSecurityIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/security/AbstractCoapSecurityIntegrationTest.java index 839b8c7cad..cfb449aa33 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/security/AbstractCoapSecurityIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/security/AbstractCoapSecurityIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/security/sql/CoapClientX509SecurityJksIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/security/sql/CoapClientX509SecurityJksIntegrationTest.java index b00bb4f59a..01ebf8169d 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/security/sql/CoapClientX509SecurityJksIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/security/sql/CoapClientX509SecurityJksIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/security/sql/CoapClientX509SecurityPemIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/security/sql/CoapClientX509SecurityPemIntegrationTest.java index e22dfaf465..e1fd9bb720 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/security/sql/CoapClientX509SecurityPemIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/security/sql/CoapClientX509SecurityPemIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/attributes/CoapAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/attributes/CoapAttributesIntegrationTest.java index f3a7df94ac..54d3921aa0 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/attributes/CoapAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/attributes/CoapAttributesIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/attributes/CoapAttributesJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/attributes/CoapAttributesJsonIntegrationTest.java index 2911370051..c7e1047860 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/attributes/CoapAttributesJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/attributes/CoapAttributesJsonIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/attributes/CoapAttributesProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/attributes/CoapAttributesProtoIntegrationTest.java index 453b62d7ee..d0cd76e4ed 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/attributes/CoapAttributesProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/attributes/CoapAttributesProtoIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesIntegrationTest.java index 91c765e5d4..2d98838ce7 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesJsonIntegrationTest.java index c4956884eb..fe6c328f5e 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesJsonIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesProtoIntegrationTest.java index 986a992a52..0448001dee 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesProtoIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/nosql/CoapTimeseriesNoSqlIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/nosql/CoapTimeseriesNoSqlIntegrationTest.java index 663ddd0a16..cd0ae004a5 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/nosql/CoapTimeseriesNoSqlIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/nosql/CoapTimeseriesNoSqlIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/nosql/CoapTimeseriesNoSqlJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/nosql/CoapTimeseriesNoSqlJsonIntegrationTest.java index 961ba633c2..02f670c78b 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/nosql/CoapTimeseriesNoSqlJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/nosql/CoapTimeseriesNoSqlJsonIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/nosql/CoapTimeseriesNoSqlProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/nosql/CoapTimeseriesNoSqlProtoIntegrationTest.java index 1e047c5a6d..965091829d 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/nosql/CoapTimeseriesNoSqlProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/nosql/CoapTimeseriesNoSqlProtoIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/sql/CoapTimeseriesSqlIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/sql/CoapTimeseriesSqlIntegrationTest.java index 04aed9a572..7ac581330c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/sql/CoapTimeseriesSqlIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/sql/CoapTimeseriesSqlIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/sql/CoapTimeseriesSqlJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/sql/CoapTimeseriesSqlJsonIntegrationTest.java index d5b731fa29..e1d1ef8fae 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/sql/CoapTimeseriesSqlJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/sql/CoapTimeseriesSqlJsonIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/sql/CoapTimeseriesSqlProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/sql/CoapTimeseriesSqlProtoIntegrationTest.java index 643c5e512a..99db52a6ed 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/sql/CoapTimeseriesSqlProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/sql/CoapTimeseriesSqlProtoIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/x509/CertPrivateKey.java b/application/src/test/java/org/thingsboard/server/transport/coap/x509/CertPrivateKey.java index 9bc39369f9..46d20bf766 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/x509/CertPrivateKey.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/x509/CertPrivateKey.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/x509/CoapClientX509Test.java b/application/src/test/java/org/thingsboard/server/transport/coap/x509/CoapClientX509Test.java index 0afbbc4d79..0f65298d86 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/x509/CoapClientX509Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/x509/CoapClientX509Test.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/x509/TbAdvancedCertificateVerifier.java b/application/src/test/java/org/thingsboard/server/transport/coap/x509/TbAdvancedCertificateVerifier.java index d1ddf73aad..3998a04b41 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/x509/TbAdvancedCertificateVerifier.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/x509/TbAdvancedCertificateVerifier.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java index b4c38f57bc..096903f8df 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -85,6 +85,7 @@ import org.thingsboard.server.transport.lwm2m.server.client.ResourceUpdateResult import org.thingsboard.server.transport.lwm2m.server.uplink.DefaultLwM2mUplinkMsgHandler; import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler; +import java.io.IOException; import java.net.ServerSocket; import java.util.ArrayList; import java.util.Arrays; @@ -123,11 +124,11 @@ import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfil import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.lwm2mClientResources; import static org.thingsboard.server.transport.lwm2m.ota.AbstractOtaLwM2MIntegrationTest.CLIENT_LWM2M_SETTINGS_19; -@TestPropertySource(properties = { - "transport.lwm2m.enabled=true", -}) @Slf4j @DaoSqlTest +@TestPropertySource(properties = { + "transport.lwm2m.enabled=true" +}) public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportIntegrationTest { @SpyBean @@ -148,9 +149,6 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte public static final String host = "localhost"; public static final String hostBs = "localhost"; public static final Integer shortServerId = 123; - public static final Integer shortServerIdBs0 = 0; - public static final int serverId = 1; - public static final int serverIdBs = 0; public static final String COAP = "coap://"; public static final String COAPS = "coaps://"; @@ -320,7 +318,7 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte @After public void after() throws Exception { - this.clientDestroy(true); + this.clientDestroy(); if (executor != null && !executor.isShutdown()) { executor.shutdownNow(); } @@ -569,7 +567,7 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte public void createNewClient(Security security, Security securityBs, boolean isRpc, String endpoint, Integer clientDtlsCidLength, boolean queueMode, String deviceIdStr, Integer value3_0_9) throws Exception { - this.clientDestroy(false); + this.clientDestroy(); lwM2MTestClient = new LwM2MTestClient(this.executor, endpoint, resources); try (ServerSocket socket = new ServerSocket(0)) { @@ -656,13 +654,30 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte } - private void clientDestroy(boolean isAfter) { + private void clientDestroy() { try { if (lwM2MTestClient != null && lwM2MTestClient.getLeshanClient() != null) { - if (isAfter) { - sendObserveCancelAllWithAwait(lwM2MTestClient.getDeviceIdStr()); - awaitDeleteDevice(lwM2MTestClient.getDeviceIdStr()); + boolean serverAlive = false; + for (int port = AbstractLwM2MIntegrationTest.port; port <= securityPortBs; port++) { + try (ServerSocket socket = new ServerSocket(port)) { + log.info("Port {} is free.", port); + } catch (IOException e) { + log.debug("Port {} is busy — CoAP server still active.", port); + serverAlive = true; + break; + } + } + if (serverAlive) { + try { + sendObserveCancelAllWithAwait(lwM2MTestClient.getDeviceIdStr()); + awaitDeleteDevice(lwM2MTestClient.getDeviceIdStr()); + } catch (Exception e) { + log.warn("Failed to cleanup LwM2M observations before destroy: {}", e.getMessage()); + } + } else { + log.info("No active CoAP server found on ports 5685–5688. Skipping observe cleanup."); } + lwM2MTestClient.destroy(); } } catch (Exception e) { @@ -710,10 +725,10 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte return bootstrap; } - private AbstractLwM2MBootstrapServerCredential getBootstrapServerCredentialNoSec(boolean isBootstrap) { + protected AbstractLwM2MBootstrapServerCredential getBootstrapServerCredentialNoSec(boolean isBootstrap) { AbstractLwM2MBootstrapServerCredential bootstrapServerCredential = new NoSecLwM2MBootstrapServerCredential(); bootstrapServerCredential.setServerPublicKey(""); - bootstrapServerCredential.setShortServerId(isBootstrap ? shortServerIdBs0 : shortServerId); + bootstrapServerCredential.setShortServerId(isBootstrap ? null : shortServerId); bootstrapServerCredential.setBootstrapServerIs(isBootstrap); bootstrapServerCredential.setHost(isBootstrap ? hostBs : host); bootstrapServerCredential.setPort(isBootstrap ? portBs : port); diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/Lwm2mTestHelper.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/Lwm2mTestHelper.java index 83a9cd14c5..a307d53054 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/Lwm2mTestHelper.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/Lwm2mTestHelper.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,8 +24,6 @@ public class Lwm2mTestHelper { public static final int TEMPERATURE_SENSOR = 3303; // Ids in Client - public static final int OBJECT_ID_0 = 0; - public static final int OBJECT_ID_1 = 1; public static final int OBJECT_INSTANCE_ID_0 = 0; public static final int OBJECT_INSTANCE_ID_1 = 1; public static final int OBJECT_INSTANCE_ID_2 = 2; diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/attributes/DefaultLwM2mAttributeParserTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/attributes/DefaultLwM2mAttributeParserTest.java index e6b1c7f3eb..7838e4729e 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/attributes/DefaultLwM2mAttributeParserTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/attributes/DefaultLwM2mAttributeParserTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/attributes/DefaultLwM2mLinkParserTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/attributes/DefaultLwM2mLinkParserTest.java index 3691c31bde..95fc23d4e2 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/attributes/DefaultLwM2mLinkParserTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/attributes/DefaultLwM2mLinkParserTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/attributes/LwM2mAttributesTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/attributes/LwM2mAttributesTest.java index 1d360c3d44..56d52c0f5d 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/attributes/LwM2mAttributesTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/attributes/LwM2mAttributesTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/DtlsSessionLogger.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/DtlsSessionLogger.java index db68122d35..a3617dcb6c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/DtlsSessionLogger.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/DtlsSessionLogger.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java index 8e247a8814..e2d57077a3 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MLocationParams.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MLocationParams.java index 1be9e14b89..5f4939f893 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MLocationParams.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MLocationParams.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java index 3dc87cf245..bda2ef17e1 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -68,6 +68,9 @@ import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandle import org.thingsboard.server.transport.lwm2m.utils.LwM2mValueConverterImpl; import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.HashMap; @@ -77,6 +80,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_CONNECTION_ID_LENGTH; import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY; @@ -88,10 +92,7 @@ import static org.eclipse.leshan.core.LwM2mId.SECURITY; import static org.eclipse.leshan.core.LwM2mId.SERVER; import static org.eclipse.leshan.core.LwM2mId.SOFTWARE_MANAGEMENT; import static org.eclipse.leshan.core.node.codec.DefaultLwM2mEncoder.getDefaultPathEncoder; -import static org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest.serverId; -import static org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest.serverIdBs; import static org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest.shortServerId; -import static org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest.shortServerIdBs0; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.BINARY_APP_DATA_CONTAINER; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_BOOTSTRAP_FAILURE; @@ -141,7 +142,7 @@ public class LwM2MTestClient { private LwM2mTemperatureSensor lwM2mTemperatureSensor12; private String deviceIdStr; - public void init(Security security, Security securityBs, int port, boolean isRpc, + public void init(Security securityLwm2m, Security securityBs, int port, boolean isRpc, LwM2mUplinkMsgHandler defaultLwM2mUplinkMsgHandler, LwM2mClientContext clientContext, Integer cIdLength, boolean queueMode, boolean supportFormatOnly_SenMLJSON_SenMLCBOR, Integer value3_0_9) throws InvalidDDFFileException, IOException { @@ -149,55 +150,33 @@ public class LwM2MTestClient { this.defaultLwM2mUplinkMsgHandlerTest = defaultLwM2mUplinkMsgHandler; this.clientContext = clientContext; - List models = ObjectLoader.loadAllDefault(); - for (String resourceName : lwm2mClientResources) { - models.addAll(ObjectLoader.loadDdfFile(LwM2MTestClient.class.getClassLoader().getResourceAsStream("lwm2m/" + resourceName), resourceName)); + ObjectsInitializer initializer = createFreshInitializer(); + + // SECURITY + if (securityLwm2m != null && securityLwm2m.getId() != null) { + forceNullSecurityId(securityLwm2m); } - if (this.modelResources != null) { - List modelsRes = new ArrayList<>(); - for (String resourceName : this.modelResources) { - modelsRes.addAll(ObjectLoader.loadDdfFile(LwM2MTestClient.class.getClassLoader().getResourceAsStream("lwm2m/" + resourceName), resourceName)); - } - Set idsToRemove = new HashSet<>(); - for (ObjectModel model : modelsRes) { - idsToRemove.add(model.id); - } - models.removeIf(model -> idsToRemove.contains(model.id)); - models.addAll(modelsRes); + if (securityBs!= null && securityBs.getId() != null) { + forceNullSecurityId(securityBs); } - - LwM2mModel model = new StaticModel(models); - ObjectsInitializer initializer = new ObjectsInitializer(model); - if (securityBs != null && security != null) { - // SECURITY - security.setId(serverId); - securityBs.setId(serverIdBs); - LwM2mInstanceEnabler[] instances = new LwM2mInstanceEnabler[]{securityBs, security}; - initializer.setClassForObject(SECURITY, Security.class); - initializer.setInstancesForObject(SECURITY, instances); - // SERVER - Server lwm2mServer = new Server(shortServerId, TimeUnit.MINUTES.toSeconds(60)); - lwm2mServer.setId(serverId); - Server serverBs = new Server(shortServerIdBs0, TimeUnit.MINUTES.toSeconds(60)); - serverBs.setId(serverIdBs); - instances = new LwM2mInstanceEnabler[]{serverBs, lwm2mServer}; - initializer.setClassForObject(SERVER, Server.class); - initializer.setInstancesForObject(SERVER, instances); + if (securityBs != null && securityLwm2m != null) { + log.warn("Security Both: securityBs: [{}] and security Lwm2m [{}]", securityBs.getId(), securityLwm2m.getId()); + initializer.setInstancesForObject(SECURITY, securityBs, securityLwm2m); } else if (securityBs != null) { - // SECURITY + log.warn("Security BS only: securityBs: [{}] ", securityBs.getId()); initializer.setInstancesForObject(SECURITY, securityBs); - // SERVER - initializer.setClassForObject(SERVER, Server.class); - } else { + } else if (securityLwm2m != null){ // SECURITY - initializer.setInstancesForObject(SECURITY, security); - // SERVER - Server lwm2mServer = new Server(shortServerId, TimeUnit.MINUTES.toSeconds(60)); - lwm2mServer.setId(serverId); - initializer.setInstancesForObject(SERVER, lwm2mServer); + log.warn("Security Lwm2m only: security Lwm2m [{}]", securityLwm2m.getId()); + initializer.setInstancesForObject(SECURITY, securityLwm2m); } - + // SERVER + Server serverLwm2m = new Server(shortServerId, TimeUnit.MINUTES.toSeconds(60)); + initializer.setInstancesForObject(SERVER, serverLwm2m); + // DEVICE initializer.setInstancesForObject(DEVICE, lwM2MDevice = new SimpleLwM2MDevice(executor, value3_0_9)); + + // OTHER t initializer.setInstancesForObject(FIRMWARE, fwLwM2MDevice = new FwLwM2MDevice()); initializer.setInstancesForObject(SOFTWARE_MANAGEMENT, swLwM2MDevice = new SwLwM2MDevice()); initializer.setClassForObject(ACCESS_CONTROL, DummyInstanceEnabler.class); @@ -444,25 +423,43 @@ public class LwM2MTestClient { public void destroy() { if (leshanClient != null) { - leshanClient.destroy(true); - } - if (lwM2MDevice != null) { - lwM2MDevice.destroy(); - } - if (fwLwM2MDevice != null) { - fwLwM2MDevice.destroy(); - } - if (swLwM2MDevice != null) { - swLwM2MDevice.destroy(); - } - if (lwM2MBinaryAppDataContainer != null) { - lwM2MBinaryAppDataContainer.destroy(); + try { + leshanClient.destroy(true); + } catch (Exception e) { + log.warn("Failed to destroy Leshan client", e); + } finally { + leshanClient = null; + } } - if (lwM2MTemperatureSensor != null) { - lwM2MTemperatureSensor.destroy(); + + // ThingsBoard custom LwM2M objects + destroySafe(lwM2MDevice); + destroySafe(fwLwM2MDevice); + destroySafe(swLwM2MDevice); + destroySafe(lwM2MBinaryAppDataContainer); + destroySafe(lwM2MTemperatureSensor); + + lwM2MDevice = null; + fwLwM2MDevice = null; + swLwM2MDevice = null; + lwM2MBinaryAppDataContainer = null; + lwM2MTemperatureSensor = null; + } + + + private void destroySafe(Object obj) { + if (obj == null) return; + try { + Method destroy = obj.getClass().getMethod("destroy"); + destroy.invoke(obj); + } catch (NoSuchMethodException e) { + // не має destroy() — ігноруємо + } catch (Exception e) { + log.warn("Failed to destroy {}", obj.getClass().getSimpleName(), e); } } + public void start(boolean isStartLw) { if (leshanClient != null) { leshanClient.start(); @@ -484,4 +481,59 @@ public class LwM2MTestClient { LwM2mClient lwM2MClient = this.clientContext.getClientByEndpoint(endpoint); Mockito.doAnswer(invocationOnMock -> null).when(defaultLwM2mUplinkMsgHandlerTest).initAttributes(lwM2MClient, true); } + + private ObjectsInitializer createFreshInitializer() { + List models = new ArrayList<>(ObjectLoader.loadAllDefault()); + for (String resourceName : lwm2mClientResources) { + try (InputStream in = LwM2MTestClient.class.getClassLoader() + .getResourceAsStream("lwm2m/" + resourceName)) { + models.addAll(ObjectLoader.loadDdfFile(in, resourceName)); + } catch (IOException | InvalidDDFFileException e) { + log.warn("Failed to load resource {}", resourceName, e); + } + } + if (this.modelResources != null) { + List modelsRes = new ArrayList<>(); + for (String resourceName : this.modelResources) { + try (InputStream in = LwM2MTestClient.class.getClassLoader() + .getResourceAsStream("lwm2m/" + resourceName)) { + modelsRes.addAll(ObjectLoader.loadDdfFile(in, resourceName)); + } catch (IOException | InvalidDDFFileException e) { + log.warn("Failed to load resource {}", resourceName, e); + } + } + Set idsToRemove = modelsRes.stream() + .map(m -> m.id) + .collect(Collectors.toSet()); + models.removeIf(m -> idsToRemove.contains(m.id)); + models.addAll(modelsRes); + } + LwM2mModel model = new StaticModel(models); + return new ObjectsInitializer(model); + } + + private void forceNullSecurityId(Security security) { + if (security == null) { + return; + } + try { + Field field = security.getClass().getDeclaredField("id"); + field.setAccessible(true); + field.set(security, null); + log.info("[forceNullSecurityId] Set id=null for {}", security); + } catch (NoSuchFieldException e) { + try { + //(SecurityObjectInstance) + Field field = security.getClass().getSuperclass().getDeclaredField("id"); + field.setAccessible(true); + field.set(security, null); + log.info("[forceNullSecurityId] Set id=null for {} (via superclass)", security); + } catch (Exception ex) { + log.error("[forceNullSecurityId] Field 'id' not found for {}", security.getClass(), ex); + } + } catch (Exception e) { + log.error("[forceNullSecurityId] Failed to set id=null for {}", security.getClass(), e); + } + } } + diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mBinaryAppDataContainer.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mBinaryAppDataContainer.java index 9bd23caea6..6708588cd7 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mBinaryAppDataContainer.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mBinaryAppDataContainer.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mLocation.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mLocation.java index 00df35b23e..3133509076 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mLocation.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mLocation.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mTemperatureSensor.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mTemperatureSensor.java index 0730788f58..64978af44d 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mTemperatureSensor.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mTemperatureSensor.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/Lwm2mServer.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/Lwm2mServer.java index 0481e7e654..19cbda451d 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/Lwm2mServer.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/Lwm2mServer.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java index 5157d5597d..191bc97579 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java index d457944b61..76b9827f5a 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/AbstractOtaLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/AbstractOtaLwM2MIntegrationTest.java index 0681f00d27..1d49092074 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/AbstractOtaLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/AbstractOtaLwM2MIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota5LwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota5LwM2MIntegrationTest.java index 0943491045..9cbd02f613 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota5LwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota5LwM2MIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota9LwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota9LwM2MIntegrationTest.java index 5371a6a3c0..dd2af41c17 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota9LwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota9LwM2MIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_0_Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_0_Test.java index f333fd012c..949176cc60 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_0_Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_0_Test.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_1_Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_1_Test.java index b39b3d1130..cd48917eb2 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_1_Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_1_Test.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_2_Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_2_Test.java index 8c07379004..133e0a20a3 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_2_Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationObserve_Ver_1_2_Test.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationTest.java index 508878d584..3c4bdcd3e7 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -48,12 +48,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.eclipse.leshan.core.LwM2mId.ACCESS_CONTROL; import static org.eclipse.leshan.core.LwM2mId.DEVICE; import static org.eclipse.leshan.core.LwM2mId.FIRMWARE; +import static org.eclipse.leshan.core.LwM2mId.SECURITY; import static org.eclipse.leshan.core.LwM2mId.SERVER; import static org.eclipse.leshan.core.LwM2mId.SOFTWARE_MANAGEMENT; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.BINARY_APP_DATA_CONTAINER; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; -import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_ID_0; -import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_ID_1; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_0; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_1; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_12; @@ -116,6 +115,10 @@ public abstract class AbstractRpcLwM2MIntegrationTest extends AbstractLwM2MInteg initRpc(0); } else if (this.getClass().getSimpleName().equals("RpcLwm2mIntegrationReadCollectedValueTest")) { initRpc(3303); + } else if (this.getClass().getSimpleName().equals("RpcLwm2mIntegrationInitReadCompositeAllTest")) { + initRpc(2); + }else if (this.getClass().getSimpleName().equals("RpcLwm2mIntegrationInitReadCompositeByObjectTest")) { + initRpc(3); } else { initRpc(1); } @@ -141,10 +144,10 @@ public abstract class AbstractRpcLwM2MIntegrationTest extends AbstractLwM2MInteg }); } }); - String ver_Id_0 = lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(OBJECT_ID_0).version; - String ver_Id_1 = lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(OBJECT_ID_1).version; - objectIdVer_0 = "/" + OBJECT_ID_0 + "_" + ver_Id_0; - objectIdVer_1 = "/" + OBJECT_ID_1 + "_" + ver_Id_1; + String ver_Id_0 = lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(SECURITY).version; + String ver_Id_1 = lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(SERVER).version; + objectIdVer_0 = "/" + SECURITY + "_" + ver_Id_0; + objectIdVer_1 = "/" + SERVER + "_" + ver_Id_1; objectIdVer_2 = (String) expectedObjectIdVers.stream().filter(path -> ((String) path).startsWith("/" + ACCESS_CONTROL)).findFirst().get(); objectIdVer_3 = (String) expectedObjectIdVers.stream().filter(PREDICATE_3).findFirst().get(); objectIdVer_19 = (String) expectedObjectIdVers.stream().filter(path -> ((String) path).startsWith("/" + BINARY_APP_DATA_CONTAINER)).findFirst().get(); @@ -222,10 +225,67 @@ public abstract class AbstractRpcLwM2MIntegrationTest extends AbstractLwM2MInteg " ],\n" + " \"attributeLwm2m\": {}\n" + " }"; + String INIT_READ_TELEMETRY_ATTRIBUTE_AS_OBSERVE_STRATEGY_ALL = + " {\n" + + " \"keyName\": {\n" + + " \"/3_1.2/0/9\": \"batteryLevel\",\n" + + " \"/3_1.2/0/20\": \"batteryStatus\",\n" + + " \"/19_1.1/0/2\": \"dataCreationTime\",\n" + + " \"/5_1.2/0/6\": \"pkgname\",\n" + + " \"/5_1.2/0/7\": \"pkgversion\",\n" + + " \"/5_1.2/0/9\": \"firmwareUpdateDeliveryMethod\"\n" + + " },\n" + + " \"observe\": [\n" + + " \"/3_1.2/0/20\"\n" + + " ],\n" + + " \"attribute\": [\n" + + " \"/5_1.2/0/6\",\n" + + " \"/5_1.2/0/7\"\n" + + " ],\n" + + " \"telemetry\": [\n" + + " \"/3_1.2/0/9\",\n" + + " \"/3_1.2/0/20\",\n" + + " \"/5_1.2/0/9\",\n" + + " \"/19_1.1/0/2\"\n" + + " ],\n" + + " \"attributeLwm2m\": {},\n" + + " \"initAttrTelAsObsStrategy\": true,\n" + + " \"observeStrategy\": 1\n" + + " }"; + String INIT_READ_TELEMETRY_ATTRIBUTE_AS_OBSERVE_STRATEGY_BY_OBJECT = + " {\n" + + " \"keyName\": {\n" + + " \"/3_1.2/0/9\": \"batteryLevel\",\n" + + " \"/3_1.2/0/20\": \"batteryStatus\",\n" + + " \"/19_1.1/0/2\": \"dataCreationTime\",\n" + + " \"/5_1.2/0/6\": \"pkgname\",\n" + + " \"/5_1.2/0/7\": \"pkgversion\",\n" + + " \"/5_1.2/0/9\": \"firmwareUpdateDeliveryMethod\"\n" + + " },\n" + + " \"observe\": [\n" + + " \"/3_1.2/0/9\"\n" + + " ],\n" + + " \"attribute\": [\n" + + " \"/5_1.2/0/6\",\n" + + " \"/5_1.2/0/7\"\n" + + " ],\n" + + " \"telemetry\": [\n" + + " \"/3_1.2/0/9\",\n" + + " \"/3_1.2/0/20\",\n" + + " \"/5_1.2/0/9\",\n" + + " \"/19_1.1/0/2\"\n" + + " ],\n" + + " \"attributeLwm2m\": {},\n" + + " \"initAttrTelAsObsStrategy\": true,\n" + + " \"observeStrategy\": 2\n" + + " }"; + CONFIG_PROFILE_WITH_PARAMS_RPC = switch (typeConfigProfile) { case 0 -> ATTRIBUTES_TELEMETRY_WITH_PARAMS_RPC_WITH_OBSERVE; case 1 -> TELEMETRY_WITH_PARAMS_RPC_WITHOUT_OBSERVE; + case 2 -> INIT_READ_TELEMETRY_ATTRIBUTE_AS_OBSERVE_STRATEGY_ALL; + case 3 -> INIT_READ_TELEMETRY_ATTRIBUTE_AS_OBSERVE_STRATEGY_BY_OBJECT; case 3303 -> TELEMETRY_WITH_PARAMS_RPC_COLLECTED_VALUE; default -> throw new IllegalStateException("Unexpected value: " + typeConfigProfile); }; diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2MIntegrationObserveCompositeTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2MIntegrationObserveCompositeTest.java index 59b1dd569d..79eec1e4d6 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2MIntegrationObserveCompositeTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2MIntegrationObserveCompositeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationCreateTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationCreateTest.java index ec0dcd7949..b789ded3e9 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationCreateTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationCreateTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDataReceivedFromClientTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDataReceivedFromClientTest.java index 8cb5e06248..e0ecc6a4c5 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDataReceivedFromClientTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDataReceivedFromClientTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDeleteTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDeleteTest.java index f7713ead34..123d237553 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDeleteTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDeleteTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverTest.java index 77960b1c54..9ce89de4fd 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverWriteAttributesTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverWriteAttributesTest.java index 2a4a401107..912a5e4755 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverWriteAttributesTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDiscoverWriteAttributesTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java index 2ae5bad5a5..f5b4538c1c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationInitReadCompositeAllTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationInitReadCompositeAllTest.java new file mode 100644 index 0000000000..ea23d2a61d --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationInitReadCompositeAllTest.java @@ -0,0 +1,92 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.rpc.sql; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationTest; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_0; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_2; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_6; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_7; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_9; + +@Slf4j +public class RpcLwm2mIntegrationInitReadCompositeAllTest extends AbstractRpcLwM2MIntegrationTest { + + /** + " \"/3_1.2/0/9\": \"batteryLevel\", - Telemetry + " \"/3_1.2/0/20\": \"batteryStatus\" - Observe, Telemetry + " \"/5_1.2/0/6\": \"pkgname\" - Attributes + " \"/5_1.2/0/7\": \"pkgversion\" - Attributes + " \"/5_1.2/0/9\": \"firmwareUpdateDeliveryMethod\"\ - Telemetry + " \"/19_1.1/0/2\": \"dataCreationTime\" - Telemetry + * "observeStrategy": 1 + */ + @Test + public void testInitReadCompositeAsObserveStrategyCompositeAll() throws Exception { + + + // init test + String RESOURCE_3_9 = "batteryLevel"; + String RESOURCE_3_20 = "batteryStatus"; + String RESOURCE_5_6 = "pkgname"; + String RESOURCE_5_7 = "pkgversion"; + String RESOURCE_5_9 = "firmwareUpdateDeliveryMethod"; + String RESOURCE_19_2 = "dataCreationTime"; + + String idVwr_3_0_20 = idVer_3_0_9 = objectIdVer_3 + "/" + OBJECT_INSTANCE_ID_0 + "/" + 20; + String IdVer5_0_6 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_6; + String IdVer5_0_7 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_7; + String IdVer5_0_9 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_9; + String idVer_19_0_2 = objectIdVer_19 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_2; + countUpdateAttrTelemetryResource(idVer_3_0_9); + countUpdateAttrTelemetryResource(idVwr_3_0_20); + countUpdateAttrTelemetryResource(IdVer5_0_6); + countUpdateAttrTelemetryResource(IdVer5_0_7); + countUpdateAttrTelemetryResource(IdVer5_0_9); + countUpdateAttrTelemetryResource(idVer_19_0_2); + + + AtomicReference actualValues = new AtomicReference<>(); + await().atMost(40, SECONDS).until(() -> { + actualValues.set(doGetAsync( + "/api/plugins/telemetry/DEVICE/" + lwM2MTestClient.getDeviceIdStr() + "/values/timeseries?keys=" + + RESOURCE_3_9 + "," + RESOURCE_3_20 + "," + RESOURCE_5_9 + "," + RESOURCE_19_2, ObjectNode.class)); + return actualValues.get() != null && !actualValues.get().isEmpty() + && !actualValues.get().get(RESOURCE_3_9).isEmpty() + && !actualValues.get().get(RESOURCE_3_20).isEmpty() + && !actualValues.get().get(RESOURCE_5_9).isEmpty() + && !actualValues.get().get(RESOURCE_19_2).isEmpty(); + }); + + AtomicReference> actualKeys =new AtomicReference<>(); + await().atMost(40, SECONDS).until(() -> { + actualKeys.set(doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + lwM2MTestClient.getDeviceIdStr() + "/keys/attributes/CLIENT_SCOPE", new TypeReference<>() { + })); + return actualKeys.get() != null && !actualKeys.get().isEmpty() && !actualKeys.get().isEmpty() + && actualKeys.get().contains(RESOURCE_5_6)&& actualKeys.get().contains(RESOURCE_5_7); + }); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationInitReadCompositeByObjectTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationInitReadCompositeByObjectTest.java new file mode 100644 index 0000000000..6c5f04da77 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationInitReadCompositeByObjectTest.java @@ -0,0 +1,92 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.rpc.sql; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationTest; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_0; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_2; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_6; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_7; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_9; + +@Slf4j +public class RpcLwm2mIntegrationInitReadCompositeByObjectTest extends AbstractRpcLwM2MIntegrationTest { + + /** + " \"/3_1.2/0/9\": \"batteryLevel\", - Telemetry + " \"/3_1.2/0/20\": \"batteryStatus\" - Observe, Telemetry + " \"/5_1.2/0/6\": \"pkgname\" - Attributes + " \"/5_1.2/0/7\": \"pkgversion\" - Attributes + " \"/5_1.2/0/9\": \"firmwareUpdateDeliveryMethod\"\ - Telemetry + " \"/19_1.1/0/2\": \"dataCreationTime\" - Telemetry + * "observeStrategy": 2 + */ + @Test + public void testInitReadCompositeAsObserveStrategyCompositeByObject() throws Exception { + + + // init test + String RESOURCE_3_9 = "batteryLevel"; + String RESOURCE_3_20 = "batteryStatus"; + String RESOURCE_5_6 = "pkgname"; + String RESOURCE_5_7 = "pkgversion"; + String RESOURCE_5_9 = "firmwareUpdateDeliveryMethod"; + String RESOURCE_19_2 = "dataCreationTime"; + + String idVwr_3_0_20 = idVer_3_0_9 = objectIdVer_3 + "/" + OBJECT_INSTANCE_ID_0 + "/" + 20; + String IdVer5_0_6 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_6; + String IdVer5_0_7 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_7; + String IdVer5_0_9 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_9; + String idVer_19_0_2 = objectIdVer_19 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_2; + countUpdateAttrTelemetryResource(idVer_3_0_9); + countUpdateAttrTelemetryResource(idVwr_3_0_20); + countUpdateAttrTelemetryResource(IdVer5_0_6); + countUpdateAttrTelemetryResource(IdVer5_0_7); + countUpdateAttrTelemetryResource(IdVer5_0_9); + countUpdateAttrTelemetryResource(idVer_19_0_2); + + + AtomicReference actualValues = new AtomicReference<>(); + await().atMost(40, SECONDS).until(() -> { + actualValues.set(doGetAsync( + "/api/plugins/telemetry/DEVICE/" + lwM2MTestClient.getDeviceIdStr() + "/values/timeseries?keys=" + + RESOURCE_3_9 + "," + RESOURCE_3_20 + "," + RESOURCE_5_9 + "," + RESOURCE_19_2, ObjectNode.class)); + return actualValues.get() != null && !actualValues.get().isEmpty() + && !actualValues.get().get(RESOURCE_3_9).isEmpty() + && !actualValues.get().get(RESOURCE_3_20).isEmpty() + && !actualValues.get().get(RESOURCE_5_9).isEmpty() + && !actualValues.get().get(RESOURCE_19_2).isEmpty(); + }); + + AtomicReference> actualKeys =new AtomicReference<>(); + await().atMost(40, SECONDS).until(() -> { + actualKeys.set(doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + lwM2MTestClient.getDeviceIdStr() + "/keys/attributes/CLIENT_SCOPE", new TypeReference<>() { + })); + return actualKeys.get() != null && !actualKeys.get().isEmpty() && !actualKeys.get().isEmpty() + && actualKeys.get().contains(RESOURCE_5_6)&& actualKeys.get().contains(RESOURCE_5_7); + }); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveTest.java index 505cebff69..73341c741d 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer10Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer10Test.java index e6f035e5cc..6fd432ab6c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer10Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer10Test.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer11Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer11Test.java index a64ff43b92..ab9d855e09 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer11Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer11Test.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer12Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer12Test.java index 89062dd303..62de3bd08b 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer12Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationObserveVer12Test.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadCollectedValueTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadCollectedValueTest.java index d93ef611ab..432fac6d1a 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadCollectedValueTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadCollectedValueTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadTest.java index 90b373b16c..fde11785c3 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,8 +21,10 @@ import org.eclipse.leshan.core.ResponseCode; import org.eclipse.leshan.core.node.LwM2mPath; import org.junit.Test; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationTest; +import static org.eclipse.leshan.core.LwM2mId.ACCESS_CONTROL; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -53,18 +55,18 @@ public class RpcLwm2mIntegrationReadTest extends AbstractRpcLwM2MIntegrationTest try { expectedObjectIdVers.forEach(expected -> { try { - String actualResult = sendRPCById((String) expected); String expectedObjectId = pathIdVerToObjectId((String) expected); LwM2mPath expectedPath = new LwM2mPath(expectedObjectId); - ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); - assertEquals(ResponseCode.CONTENT.getName(), rpcActualResult.get("result").asText()); - String expectedObjectInstances = "LwM2mObject [id=" + expectedPath.getObjectId() + ", instances={0=LwM2mObjectInstance [id=0, resources="; - if (expectedPath.getObjectId() == 1) { - expectedObjectInstances = "LwM2mObject [id=1, instances={1="; - } else if (expectedPath.getObjectId() == 2) { - expectedObjectInstances = "LwM2mObject [id=2, instances={}]"; + if (expectedPath.getObjectId() > ACCESS_CONTROL) { + String actualResult = sendRPCByIdSync((String) expected); + if (StringUtils.isNoneBlank(actualResult)) { + log.warn(" expectedPath: [{}]", expectedPath); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.CONTENT.getName(), rpcActualResult.get("result").asText()); + String expectedObjectInstances = "LwM2mObject [id=" + expectedPath.getObjectId() + ", instances={0=LwM2mObjectInstance [id=0, resources="; + assertTrue(rpcActualResult.get("value").asText().contains(expectedObjectInstances)); + } } - assertTrue(rpcActualResult.get("value").asText().contains(expectedObjectInstances)); } catch (Exception e) { e.printStackTrace(); } @@ -83,7 +85,7 @@ public class RpcLwm2mIntegrationReadTest extends AbstractRpcLwM2MIntegrationTest public void testReadAllInstancesInClientById_Result_CONTENT_Value_IsInstances_IsResources() throws Exception { expectedObjectIdVerInstances.forEach(expected -> { try { - String actualResult = sendRPCById((String) expected); + String actualResult = sendRPCByIdAsync((String) expected); String expectedObjectId = pathIdVerToObjectId((String) expected); LwM2mPath expectedPath = new LwM2mPath(expectedObjectId); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); @@ -104,7 +106,7 @@ public class RpcLwm2mIntegrationReadTest extends AbstractRpcLwM2MIntegrationTest @Test public void testReadMultipleResourceById_Result_CONTENT_Value_IsLwM2mMultipleResource() throws Exception { String expectedIdVer = objectInstanceIdVer_3 + "/" + RESOURCE_ID_11; - String actualResult = sendRPCById(expectedIdVer); + String actualResult = sendRPCByIdAsync(expectedIdVer); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); assertEquals(ResponseCode.CONTENT.getName(), rpcActualResult.get("result").asText()); String expected = "LwM2mMultipleResource [id=" + RESOURCE_ID_11 + ", values={"; @@ -117,7 +119,7 @@ public class RpcLwm2mIntegrationReadTest extends AbstractRpcLwM2MIntegrationTest @Test public void testReadSingleResourceById_Result_CONTENT_Value_IsLwM2mSingleResource() throws Exception { String expectedIdVer = objectInstanceIdVer_3 + "/" + RESOURCE_ID_14; - String actualResult = sendRPCById(expectedIdVer); + String actualResult = sendRPCByIdAsync(expectedIdVer); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); assertEquals(ResponseCode.CONTENT.getName(), rpcActualResult.get("result").asText()); String expected = "LwM2mSingleResource [id=" + RESOURCE_ID_14 + ", value="; @@ -228,10 +230,14 @@ public class RpcLwm2mIntegrationReadTest extends AbstractRpcLwM2MIntegrationTest assertEquals(actualValue, expectedValue); } - private String sendRPCById(String path) throws Exception { + private String sendRPCByIdAsync(String path) throws Exception { String setRpcRequest = "{\"method\": \"Read\", \"params\": {\"id\": \"" + path + "\"}}"; return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), setRpcRequest, String.class, status().isOk()); } + private String sendRPCByIdSync(String path) throws Exception { + String setRpcRequest = "{\"method\": \"Read\", \"params\": {\"id\": \"" + path + "\"}}"; + return doPost("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), setRpcRequest, String.class, status().isOk()); + } private String sendRPCByKey(String key) throws Exception { String setRpcRequest = "{\"method\": \"Read\", \"params\": {\"key\": \"" + key + "\"}}"; diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteCborTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteCborTest.java index a8172051b5..201ba96c8e 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteCborTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteCborTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteTest.java index 01030c24bd..34a4a68a81 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/AbstractSecurityLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/AbstractSecurityLwM2MIntegrationTest.java index 60474f7fda..20af793052 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/AbstractSecurityLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/AbstractSecurityLwM2MIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -70,6 +70,7 @@ import java.util.concurrent.TimeUnit; import static org.awaitility.Awaitility.await; import static org.eclipse.leshan.client.object.Security.noSecBootstrap; import static org.eclipse.leshan.client.object.Security.psk; +import static org.eclipse.leshan.core.LwM2mId.SERVER; import static org.junit.Assert.assertEquals; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.PSK; @@ -78,7 +79,7 @@ import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClient import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_REGISTRATION_STARTED; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_REGISTRATION_SUCCESS; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_UPDATE_SUCCESS; -import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_ID_1; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_0; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_9; @DaoSqlTest @@ -96,7 +97,6 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M protected static final String SERVER_STORE_PWD = "server_ks_password"; protected static final String SERVER_CERT_ALIAS = "server"; protected static final String SERVER_CERT_ALIAS_BS = "bootstrap"; - protected static final Security SECURITY_NO_SEC_BS = noSecBootstrap(URI_BS);; protected final X509Certificate serverX509Cert; // server certificate signed by rootCA protected final X509Certificate serverX509CertBs; // serverBs certificate signed by rootCA protected final PublicKey serverPublicKeyFromCert; // server public key used for RPK @@ -190,13 +190,13 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M LwM2MClientState finishState) throws Exception { Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsNoSec(type)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsNoSec(createNoSecClientCredentials(clientEndpoint)); - this.basicTestConnection(null , SECURITY_NO_SEC_BS, + this.basicTestConnection(null , noSecBootstrap(URI_BS), deviceCredentials, clientEndpoint, transportConfiguration, awaitAlias, expectedStatuses, - true, + false, finishState, false); } @@ -236,12 +236,19 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M } - public void basicTestConnectionBootstrapRequestTriggerBefore(String clientEndpoint, String awaitAlias, LwM2MProfileBootstrapConfigType type) throws Exception { - Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsNoSec(type)); + public void basicTestConnectionBootstrapRequestTriggerBefore(String clientEndpoint, String awaitAlias, LwM2MProfileBootstrapConfigType type, int cnt) throws Exception { + List bootstrapServerCredentialsNoSec = getBootstrapServerCredentialsNoSec(type); + for (int i = 2; i <= cnt; i++) { + AbstractLwM2MBootstrapServerCredential bsCredential = getBootstrapServerCredentialNoSec(false); + bsCredential.setHost("0.0.0." + i); + bsCredential.setShortServerId(bsCredential.getShortServerId() + i); + bootstrapServerCredentialsNoSec.add(bsCredential); + } + Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, bootstrapServerCredentialsNoSec); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsNoSec(createNoSecClientCredentials(clientEndpoint)); this.basicTestConnectionBootstrapRequestTrigger( SECURITY_NO_SEC, - SECURITY_NO_SEC_BS, + noSecBootstrap(URI_BS), deviceCredentials, clientEndpoint, transportConfiguration, @@ -280,8 +287,8 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M }); Assert.assertTrue(lwM2MTestClient.getClientStates().containsAll(expectedStatusesLwm2m)); - String executedPath = "/" + OBJECT_ID_1 + "_" + lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(OBJECT_ID_1).version - + "/" +serverId + "/" + RESOURCE_ID_9; + String executedPath = "/" + SERVER + "_" + lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(SERVER).version + + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_9; lwM2MTestClient.setClientStates(new HashSet<>()); String actualResult = sendRPCSecurityExecuteById(executedPath, deviceIdStr, endpoint); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); @@ -357,7 +364,7 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M default: throw new IllegalStateException("Unexpected value: " + mode); } - bootstrapServerCredential.setShortServerId(isBootstrap ? shortServerIdBs0 : shortServerId); + bootstrapServerCredential.setShortServerId(isBootstrap ? null : shortServerId); bootstrapServerCredential.setBootstrapServerIs(isBootstrap); bootstrapServerCredential.setHost(isBootstrap ? hostBs : host); bootstrapServerCredential.setPort(isBootstrap ? securityPortBs : securityPort); diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength0Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength0Test.java index 7a0b0f8580..19e6a5c6b3 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength0Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength0Test.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,10 +29,12 @@ import org.thingsboard.server.dao.service.DaoSqlTest; public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength0Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest { + private static final Integer serverDtlsCidLength = 0; + protected void testNoSecDtlsCidLength(Integer dtlsCidLength) throws Exception { - testNoSecDtlsCidLength(dtlsCidLength, 0); + testNoSecDtlsCidLength(dtlsCidLength, serverDtlsCidLength); } protected void testPskDtlsCidLength(Integer dtlsCidLength) throws Exception { - testPskDtlsCidLength(dtlsCidLength, 0); + testPskDtlsCidLength(dtlsCidLength, serverDtlsCidLength); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength16Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength16Test.java new file mode 100644 index 0000000000..faa6872b3b --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength16Test.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.cid; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.dao.service.DaoSqlTest; + + +@TestPropertySource(properties = { + "transport.lwm2m.dtls.connection_id_length=16" +}) + +@DaoSqlTest +@Slf4j +public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength16Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest { + + private static final Integer serverDtlsCidLength = 16; + + protected void testNoSecDtlsCidLength(Integer clientDtlsCidLength) throws Exception { + testNoSecDtlsCidLength(clientDtlsCidLength, serverDtlsCidLength); + } + + protected void testPskDtlsCidLength(Integer clientDtlsCidLength) throws Exception { + testPskDtlsCidLength(clientDtlsCidLength, serverDtlsCidLength); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength3Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength1Test.java similarity index 75% rename from application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength3Test.java rename to application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength1Test.java index 8a65e28975..004702f19f 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength3Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength1Test.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,17 +21,20 @@ import org.thingsboard.server.dao.service.DaoSqlTest; @TestPropertySource(properties = { - "transport.lwm2m.dtls.connection_id_length=3" + "transport.lwm2m.dtls.connection_id_length=1" }) @DaoSqlTest @Slf4j -public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength3Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest { +public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength1Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest { + + + private static final Integer serverDtlsCidLength = 1; protected void testNoSecDtlsCidLength(Integer dtlsCidLength) throws Exception { - testNoSecDtlsCidLength(dtlsCidLength, 3); + testNoSecDtlsCidLength(dtlsCidLength, serverDtlsCidLength); } protected void testPskDtlsCidLength(Integer dtlsCidLength) throws Exception { - testPskDtlsCidLength(dtlsCidLength, 3); + testPskDtlsCidLength(dtlsCidLength, serverDtlsCidLength); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength2Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength2Test.java new file mode 100644 index 0000000000..3ab6de4cd3 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength2Test.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.cid; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.dao.service.DaoSqlTest; + + +@TestPropertySource(properties = { + "transport.lwm2m.dtls.connection_id_length=2" +}) + +@DaoSqlTest +@Slf4j +public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength2Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest { + + private static final Integer serverDtlsCidLength = 2; + + protected void testNoSecDtlsCidLength(Integer dtlsCidLength) throws Exception { + testNoSecDtlsCidLength(dtlsCidLength, serverDtlsCidLength); + } + protected void testPskDtlsCidLength(Integer dtlsCidLength) throws Exception { + testPskDtlsCidLength(dtlsCidLength, serverDtlsCidLength); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength4Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength4Test.java new file mode 100644 index 0000000000..168b260227 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength4Test.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.cid; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.dao.service.DaoSqlTest; + + +@TestPropertySource(properties = { + "transport.lwm2m.dtls.connection_id_length=4" +}) + +@DaoSqlTest +@Slf4j +public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength4Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest { + + private static final Integer serverDtlsCidLength = 4; + + protected void testNoSecDtlsCidLength(Integer dtlsCidLength) throws Exception { + testNoSecDtlsCidLength(dtlsCidLength, serverDtlsCidLength); + } + protected void testPskDtlsCidLength(Integer dtlsCidLength) throws Exception { + testPskDtlsCidLength(dtlsCidLength, serverDtlsCidLength); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthNullTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthNullTest.java index 9d52920072..b91362e21b 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthNullTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthNullTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,11 +28,12 @@ import org.thingsboard.server.dao.service.DaoSqlTest; @Slf4j public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLengthNullTest extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest { + private static final Integer serverDtlsCidLength = null; protected void testNoSecDtlsCidLength(Integer dtlsCidLength) throws Exception { - testNoSecDtlsCidLength(dtlsCidLength, null); + testNoSecDtlsCidLength(dtlsCidLength, serverDtlsCidLength); } protected void testPskDtlsCidLength(Integer dtlsCidLength) throws Exception { - testPskDtlsCidLength(dtlsCidLength, null); + testPskDtlsCidLength(dtlsCidLength, serverDtlsCidLength); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthTest.java index eb17f2cf7e..f07b2d4284 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,14 +17,23 @@ package org.thingsboard.server.transport.lwm2m.security.cid; import lombok.extern.slf4j.Slf4j; import org.eclipse.californium.elements.config.Configuration; +import org.eclipse.californium.scandium.DTLSConnector; +import org.eclipse.californium.scandium.dtls.Connection; +import org.eclipse.californium.scandium.dtls.ConnectionId; +import org.eclipse.californium.scandium.dtls.InMemoryReadWriteLockConnectionStore; +import org.eclipse.californium.scandium.dtls.ResumptionSupportingConnectionStore; import org.eclipse.leshan.client.californium.endpoint.CaliforniumClientEndpoint; import org.eclipse.leshan.client.californium.endpoint.CaliforniumClientEndpointsProvider; +import org.eclipse.leshan.client.servers.LwM2mServer; +import org.eclipse.leshan.core.peer.IpPeer; import org.junit.Assert; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; +import java.lang.reflect.Field; +import java.net.InetSocketAddress; import java.util.concurrent.TimeUnit; import static org.awaitility.Awaitility.await; @@ -39,13 +48,13 @@ public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLengthTest extends protected String awaitAlias; - protected void testNoSecDtlsCidLength(Integer dtlsCidLength, Integer serverDtlsCidLength) throws Exception { + protected void testNoSecDtlsCidLength(Integer clientDtlsCidLength, Integer serverDtlsCidLength) throws Exception { initDeviceCredentialsNoSek(); - basicTestConnectionDtlsCidLength(dtlsCidLength, serverDtlsCidLength); + basicTestConnectionDtlsCidLength(clientDtlsCidLength, serverDtlsCidLength); } - protected void testPskDtlsCidLength(Integer dtlsCidLength, Integer serverDtlsCidLength) throws Exception { + protected void testPskDtlsCidLength(Integer clientDtlsCidLength, Integer serverDtlsCidLength) throws Exception { initDeviceCredentialsPsk(); - basicTestConnectionDtlsCidLength(dtlsCidLength, serverDtlsCidLength); + basicTestConnectionDtlsCidLength(clientDtlsCidLength, serverDtlsCidLength); } protected void basicTestConnectionDtlsCidLength(Integer clientDtlsCidLength, @@ -69,19 +78,55 @@ public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLengthTest extends Assert.assertTrue(lwM2MTestClient.getClientDtlsCid().isEmpty()); } else { Assert.assertEquals(2L, lwM2MTestClient.getClientDtlsCid().size()); - Assert.assertTrue(lwM2MTestClient.getClientDtlsCid().keySet().contains(ON_READ_CONNECTION_ID)); - Assert.assertTrue(lwM2MTestClient.getClientDtlsCid().keySet().contains(ON_WRITE_CONNECTION_ID)); - if (serverDtlsCidLength == null) { + Assert.assertTrue(lwM2MTestClient.getClientDtlsCid().containsKey(ON_READ_CONNECTION_ID)); + Assert.assertTrue(lwM2MTestClient.getClientDtlsCid().containsKey(ON_WRITE_CONNECTION_ID)); + + LwM2mServer lwM2mServer = lwM2MTestClient.getLeshanClient().getRegisteredServers().entrySet().stream().findFirst().get().getValue(); + CaliforniumClientEndpoint lwM2mClientEndpoint = (CaliforniumClientEndpoint) lwM2MTestClient.getLeshanClient().getEndpoint(lwM2mServer); + Connection connection = getConnection(lwM2mClientEndpoint, lwM2mServer); + ConnectionId clientCid = connection.getConnectionId(); + ConnectionId readCid = connection.getEstablishedDtlsContext().getReadConnectionId(); + ConnectionId serverCid = connection.getEstablishedDtlsContext().getWriteConnectionId(); + if (serverDtlsCidLength == null || clientDtlsCidLength == null) { + // cid is not used Assert.assertNull(lwM2MTestClient.getClientDtlsCid().get(ON_WRITE_CONNECTION_ID)); Assert.assertNull(lwM2MTestClient.getClientDtlsCid().get(ON_READ_CONNECTION_ID)); + Assert.assertNull(readCid); + Assert.assertNull(serverCid); } else { + Assert.assertEquals(serverDtlsCidLength, lwM2MTestClient.getClientDtlsCid().get(ON_WRITE_CONNECTION_ID)); Assert.assertEquals(clientDtlsCidLength, lwM2MTestClient.getClientDtlsCid().get(ON_READ_CONNECTION_ID)); - if (clientDtlsCidLength == null) { - Assert.assertNull(lwM2MTestClient.getClientDtlsCid().get(ON_READ_CONNECTION_ID)); + // cid used + Assert.assertNotNull(clientCid); + Assert.assertNotNull(readCid); + if (clientDtlsCidLength > 0) { + Assert.assertEquals(clientCid, readCid); + } + Assert.assertNotNull(serverCid); + int actualServerCidLength = serverCid.getBytes().length; + int expectedServerCidLength = serverDtlsCidLength; + Assert.assertEquals(expectedServerCidLength, actualServerCidLength); + } + + if (clientCid != null) { + int actualClientCidLength = clientCid.getBytes().length; + int expectedClientCidLength; + if (clientDtlsCidLength == null || clientDtlsCidLength == 0) { + expectedClientCidLength = 3; } else { - Assert.assertEquals(Integer.valueOf(serverDtlsCidLength), lwM2MTestClient.getClientDtlsCid().get(ON_WRITE_CONNECTION_ID)); + expectedClientCidLength = clientDtlsCidLength; } + Assert.assertEquals(expectedClientCidLength, actualClientCidLength); } } } + + private static Connection getConnection(CaliforniumClientEndpoint lwM2mClientEndpoint, LwM2mServer lwM2mServer) throws NoSuchFieldException, IllegalAccessException { + DTLSConnector connector = (DTLSConnector) lwM2mClientEndpoint.getCoapEndpoint().getConnector(); + Field field = DTLSConnector.class.getDeclaredField("connectionStore"); + field.setAccessible(true); + ResumptionSupportingConnectionStore connectionStore = (InMemoryReadWriteLockConnectionStore) field.get(connector); + InetSocketAddress serverAddr = ((IpPeer) lwM2mServer.getTransportData()).getSocketAddress(); + return connectionStore.get(serverAddr); + } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/NoSecLwM2MIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/NoSecLwM2MIntegrationDtlsCidLengthTest.java index 6d529b3c08..5c2a4ceb29 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/NoSecLwM2MIntegrationDtlsCidLengthTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/NoSecLwM2MIntegrationDtlsCidLengthTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,7 +27,7 @@ public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2 @Before public void setUpNoSecDtlsCidLength() { transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(NO_SEC, NONE)); - awaitAlias = "await on client state (NoSec_Lwm2m) DtlsCidLength = 0"; + awaitAlias = "await on client state (NoSec_Lwm2m) serverDtlsCidLength = 0"; } @Test @@ -40,8 +40,23 @@ public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2 testNoSecDtlsCidLength(0); } + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testNoSecDtlsCidLength(1); + } + @Test public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { - testNoSecDtlsCidLength(2); + testNoSecDtlsCidLength(1); + } + + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testNoSecDtlsCidLength(4); + } + + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testNoSecDtlsCidLength(16); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/PskLwm2mIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/PskLwm2mIntegrationDtlsCidLengthTest.java index f478a18777..d080e1bc65 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/PskLwm2mIntegrationDtlsCidLengthTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/PskLwm2mIntegrationDtlsCidLengthTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,7 +27,7 @@ public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MI @Before public void createProfileRpc() { transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE)); - awaitAlias = "await on client state (Psk_Lwm2m) DtlsCidLength = 0"; + awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = 0"; } @Test @@ -39,10 +39,24 @@ public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MI public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_0() throws Exception { testPskDtlsCidLength(0); } + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testPskDtlsCidLength(1); + } @Test public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { testPskDtlsCidLength(2); } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testPskDtlsCidLength(4); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testPskDtlsCidLength(16); + } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_1/PskLwm2mIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_1/PskLwm2mIntegrationDtlsCidLengthTest.java new file mode 100644 index 0000000000..d39afdd504 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_1/PskLwm2mIntegrationDtlsCidLengthTest.java @@ -0,0 +1,64 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_1; + +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength0Test; +import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength1Test; + +import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.PSK; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; + +public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength1Test { + + @Before + public void createProfileRpc() { + transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE)); + awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = 1"; + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_Null() throws Exception { + testPskDtlsCidLength(null); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_0() throws Exception { + testPskDtlsCidLength(0); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testPskDtlsCidLength(1); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { + testPskDtlsCidLength(2); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testPskDtlsCidLength(4); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testPskDtlsCidLength(16); + } +} + diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_3/NoSecLwM2MIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/NoSecLwM2MIntegrationDtlsCidLengthTest.java similarity index 67% rename from application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_3/NoSecLwM2MIntegrationDtlsCidLengthTest.java rename to application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/NoSecLwM2MIntegrationDtlsCidLengthTest.java index a395f2e7e3..ca36137295 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_3/NoSecLwM2MIntegrationDtlsCidLengthTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/NoSecLwM2MIntegrationDtlsCidLengthTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,21 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_3; +package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_16; import org.junit.Before; import org.junit.Test; -import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength3Test; +import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength16Test; +import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength4Test; import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.NO_SEC; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; -public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength3Test { +public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength16Test { @Before public void setUpNoSecDtlsCidLength() { transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(NO_SEC, NONE)); - awaitAlias = "await on client state (NoSec_Lwm2m) DtlsCidLength = 3"; + awaitAlias = "await on client state (NoSec_Lwm2m) serverDtlsCidLength = 16"; } @Test @@ -40,8 +41,23 @@ public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2 testNoSecDtlsCidLength(0); } + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testNoSecDtlsCidLength(1); + } + @Test public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { testNoSecDtlsCidLength(2); } + + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testNoSecDtlsCidLength(4); + } + + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testNoSecDtlsCidLength(16); + } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/PskLwm2mIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/PskLwm2mIntegrationDtlsCidLengthTest.java new file mode 100644 index 0000000000..45b52c6ddd --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/PskLwm2mIntegrationDtlsCidLengthTest.java @@ -0,0 +1,64 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_16; + +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength16Test; +import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength4Test; + +import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.PSK; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; + +public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength16Test { + + @Before + public void createProfileRpc() { + transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE)); + awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = 16"; + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_Null() throws Exception { + testPskDtlsCidLength(null); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_0() throws Exception { + testPskDtlsCidLength(0); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testPskDtlsCidLength(1); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { + testPskDtlsCidLength(2); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testPskDtlsCidLength(4); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testPskDtlsCidLength(16); + } +} + diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_3/PskLwm2mIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_2/PskLwm2mIntegrationDtlsCidLengthTest.java similarity index 71% rename from application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_3/PskLwm2mIntegrationDtlsCidLengthTest.java rename to application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_2/PskLwm2mIntegrationDtlsCidLengthTest.java index 868a146ed7..2e0b40da3d 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_3/PskLwm2mIntegrationDtlsCidLengthTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_2/PskLwm2mIntegrationDtlsCidLengthTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,21 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_3; +package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_2; import org.junit.Before; import org.junit.Test; -import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength3Test; +import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength2Test; import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.PSK; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; -public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength3Test { +public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength2Test { @Before public void createProfileRpc() { transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE)); - awaitAlias = "await on client state (Psk_Lwm2m) DtlsCidLength = 3"; + awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = 2"; } @Test @@ -40,9 +40,24 @@ public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MI testPskDtlsCidLength(0); } + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testPskDtlsCidLength(1); + } + @Test public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { testPskDtlsCidLength(2); } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testPskDtlsCidLength(4); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testPskDtlsCidLength(16); + } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_4/PskLwm2mIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_4/PskLwm2mIntegrationDtlsCidLengthTest.java new file mode 100644 index 0000000000..aa281020a7 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_4/PskLwm2mIntegrationDtlsCidLengthTest.java @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_4; + +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength4Test; + +import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.PSK; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; + +public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength4Test { + + @Before + public void createProfileRpc() { + transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE)); + awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = 4"; + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_Null() throws Exception { + testPskDtlsCidLength(null); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_0() throws Exception { + testPskDtlsCidLength(0); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testPskDtlsCidLength(1); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { + testPskDtlsCidLength(2); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testPskDtlsCidLength(4); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testPskDtlsCidLength(16); + } +} + diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/NoSecLwM2MIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/NoSecLwM2MIntegrationDtlsCidLengthTest.java index 9e7424743a..88bc5a2622 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/NoSecLwM2MIntegrationDtlsCidLengthTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/NoSecLwM2MIntegrationDtlsCidLengthTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,7 +27,7 @@ public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2 @Before public void setUpNoSecDtlsCidLength() { transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(NO_SEC, NONE)); - awaitAlias = "await on client state (NoSec_Lwm2m) DtlsCidLength = Null"; + awaitAlias = "await on client state (NoSec_Lwm2m) serverDtlsCidLength = Null"; } @Test @@ -41,7 +41,22 @@ public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2 } @Test - public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { - testNoSecDtlsCidLength(2); + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testNoSecDtlsCidLength(1); + } + + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testNoSecDtlsCidLength(4); + } + + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_8() throws Exception { + testNoSecDtlsCidLength(8); + } + + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testNoSecDtlsCidLength(16); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/PskLwm2mIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/PskLwm2mIntegrationDtlsCidLengthTest.java index 8a8f01b3ab..37611c2bdb 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/PskLwm2mIntegrationDtlsCidLengthTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/PskLwm2mIntegrationDtlsCidLengthTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,7 +27,7 @@ public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MI @Before public void createProfileRpc() { transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE)); - awaitAlias = "await on client state (Psk_Lwm2m) DtlsCidLength = Null"; + awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = Null"; } @Test @@ -40,9 +40,24 @@ public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MI testPskDtlsCidLength(0); } + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testPskDtlsCidLength(1); + } + @Test public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { testPskDtlsCidLength(2); } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testPskDtlsCidLength(4); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testPskDtlsCidLength(16); + } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/diffPort/AbstractLwM2MIntegrationDiffPortTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/diffPort/AbstractLwM2MIntegrationDiffPortTest.java index 588004eac1..afb179cc5f 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/diffPort/AbstractLwM2MIntegrationDiffPortTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/diffPort/AbstractLwM2MIntegrationDiffPortTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/diffPort/LwM2MIntegrationDiffPortTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/diffPort/LwM2MIntegrationDiffPortTest.java index 3b87dc628c..8301905b85 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/diffPort/LwM2MIntegrationDiffPortTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/diffPort/LwM2MIntegrationDiffPortTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBS3SectionTriggerTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBS3SectionTriggerTest.java new file mode 100644 index 0000000000..8b7195141a --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBS3SectionTriggerTest.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.sql; + +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.LWM2M_ONLY; +public class NoSecLwM2MIntegrationBS3SectionTriggerTest extends AbstractSecurityLwM2MIntegrationTest { + + @Test + public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTrigger_3_ConnectBsSuccess_UpdateLwm2mSection_3_AndLm2m_1_ConnectLwm2mSuccess() throws Exception { + String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger_3" + LWM2M_ONLY.name(); + String awaitAlias = "await on client state (NoSecBS Trigger Lwm2m section)"; + basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, LWM2M_ONLY, 3); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSLwm2mOnlyNoneTriggerOneSectionTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSLwm2mOnlyNoneTriggerOneSectionTest.java new file mode 100644 index 0000000000..d386c60135 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSLwm2mOnlyNoneTriggerOneSectionTest.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.sql; + +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.LWM2M_ONLY; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; +public class NoSecLwM2MIntegrationBSLwm2mOnlyNoneTriggerOneSectionTest extends AbstractSecurityLwM2MIntegrationTest { + + @Test + public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateLwm2mSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { + String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + LWM2M_ONLY.name(); + String awaitAlias = "await on client state (NoSecBS Trigger Lwm2m section)"; + basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, LWM2M_ONLY, 1); + } + + @Test + public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateNoneSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { + String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + NONE.name(); + String awaitAlias = "await on client state (NoSecBS Trigger None section)"; + basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, NONE, 1); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSNoTriggerTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSNoTriggerTest.java new file mode 100644 index 0000000000..46deafd0a4 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSNoTriggerTest.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.sql; + +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_REGISTRATION_SUCCESS; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.BOTH; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.LWM2M_ONLY; + +public class NoSecLwM2MIntegrationBSNoTriggerTest extends AbstractSecurityLwM2MIntegrationTest { + + @Test + public void testWithNoSecConnectBsSuccess_UpdateTwoSectionsBootstrapAndLm2m_ConnectLwm2mSuccess() throws Exception { + String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "NoTrigger" + BOTH.name(); + String awaitAlias = "await on client state (NoSecBS two section)"; + basicTestConnectionStartBS(clientEndpoint, awaitAlias, BOTH, expectedStatusesRegistrationBsSuccess, ON_REGISTRATION_SUCCESS); + } + + @Test + public void testWithNoSecConnectBsSuccess_UpdateLwm2mSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { + String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "NoTrigger" + LWM2M_ONLY.name(); + String awaitAlias = "await on client state (NoSecBS Lwm2m section)"; + basicTestConnectionStartBS(clientEndpoint, awaitAlias, LWM2M_ONLY, expectedStatusesRegistrationBsSuccess, ON_REGISTRATION_SUCCESS); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSOnlyTriggerOneSectionTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSOnlyTriggerOneSectionTest.java new file mode 100644 index 0000000000..deacc8167e --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSOnlyTriggerOneSectionTest.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.sql; + +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.BOOTSTRAP_ONLY; +public class NoSecLwM2MIntegrationBSOnlyTriggerOneSectionTest extends AbstractSecurityLwM2MIntegrationTest { + + @Test + public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateBootstrapSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { + String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + BOOTSTRAP_ONLY.name(); + String awaitAlias = "await on client state (NoSecBS Trigger Bootstrap section)"; + basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, BOOTSTRAP_ONLY, 1); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSTriggerTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSTriggerTest.java new file mode 100644 index 0000000000..9ba19fbbfa --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSTriggerTest.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.sql; + +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.BOTH; + +public class NoSecLwM2MIntegrationBSTriggerTest extends AbstractSecurityLwM2MIntegrationTest { + + @Test + public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateTwoSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { + String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + BOTH.name(); + String awaitAlias = "await on client state (NoSecBS Trigger Two section)"; + basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, BOTH, 1); + } +} + diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationTest.java index 6690e6cd7b..21b4fcbce0 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,12 +19,6 @@ import org.junit.Test; import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MDeviceCredentials; import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; -import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_REGISTRATION_SUCCESS; -import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.BOOTSTRAP_ONLY; -import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.BOTH; -import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.LWM2M_ONLY; -import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; - public class NoSecLwM2MIntegrationTest extends AbstractSecurityLwM2MIntegrationTest { //Lwm2m only @@ -34,48 +28,4 @@ public class NoSecLwM2MIntegrationTest extends AbstractSecurityLwM2MIntegrationT LwM2MDeviceCredentials clientCredentials = getDeviceCredentialsNoSec(createNoSecClientCredentials(clientEndpoint)); super.basicTestConnectionObserveSingleTelemetry(SECURITY_NO_SEC, clientCredentials, clientEndpoint, false, false); } - - // Bootstrap + Lwm2m - @Test - public void testWithNoSecConnectBsSuccess_UpdateTwoSectionsBootstrapAndLm2m_ConnectLwm2mSuccess() throws Exception { - String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + BOTH.name(); - String awaitAlias = "await on client state (NoSecBS two section)"; - basicTestConnectionStartBS(clientEndpoint, awaitAlias, BOTH, expectedStatusesRegistrationBsSuccess, ON_REGISTRATION_SUCCESS); - } - - @Test - public void testWithNoSecConnectBsSuccess_UpdateLwm2mSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { - String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + LWM2M_ONLY.name(); - String awaitAlias = "await on client state (NoSecBS Lwm2m section)"; - basicTestConnectionStartBS(clientEndpoint, awaitAlias, LWM2M_ONLY, expectedStatusesRegistrationBsSuccess, ON_REGISTRATION_SUCCESS); - } - - // Bs trigger - @Test - public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateTwoSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { - String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + BOTH.name(); - String awaitAlias = "await on client state (NoSecBS Trigger Two section)"; - basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, BOTH); - } - - @Test - public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateBootstrapSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { - String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + BOOTSTRAP_ONLY.name(); - String awaitAlias = "await on client state (NoSecBS Trigger Bootstrap section)"; - basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, BOOTSTRAP_ONLY); - } - - @Test - public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateLwm2mSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { - String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + LWM2M_ONLY.name(); - String awaitAlias = "await on client state (NoSecBS Trigger Lwm2m section)"; - basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, LWM2M_ONLY); - } - - @Test - public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateNoneSectionAndLm2m_ConnectLwm2mSuccess() throws Exception { - String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + NONE.name(); - String awaitAlias = "await on client state (NoSecBS Trigger None section)"; - basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, NONE); - } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java index f2a22939f4..899fdc06a0 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -58,8 +58,7 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes Hex.decodeHex(keyPsk.toCharArray())); Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, null, null, PSK, false); - this.basicTestConnection(security, - null, + this.basicTestConnection(security, null, deviceCredentials, clientEndpoint, transportConfiguration, @@ -87,8 +86,7 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(TELEMETRY_WITH_ONE_OBSERVE, getBootstrapServerCredentialsSecure(PSK, NONE)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, null, null, PSK, false); String awaitAlias = "await on client state (Psk_Lwm2m)"; - Device lwm2mDevice = this.basicTestConnection(security, - null, + Device lwm2mDevice = this.basicTestConnection(security, null, deviceCredentials, clientEndpoint, transportConfiguration, @@ -125,8 +123,7 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(TELEMETRY_WITH_ONE_OBSERVE, getBootstrapServerCredentialsSecure(PSK, NONE)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, null, null, PSK, false); String awaitAlias = "await on client state (Psk_Lwm2m)"; - Device lwm2mDevice = this.basicTestConnection(security, - null, + Device lwm2mDevice = this.basicTestConnection(security, null, deviceCredentials, clientEndpoint, transportConfiguration, @@ -179,12 +176,12 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes clientCredentials.setEndpoint(clientEndpoint); clientCredentials.setIdentity(identity); clientCredentials.setKey(keyPsk); - Security securityBs = pskBootstrap(SECURE_URI_BS, + Security securityPskBs = pskBootstrap(SECURE_URI_BS, identity.getBytes(StandardCharsets.UTF_8), Hex.decodeHex(keyPsk.toCharArray())); Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, BOTH)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, null, null, PSK, false); - this.basicTestConnection(null, securityBs, + this.basicTestConnection(null, securityPskBs, deviceCredentials, clientEndpoint, transportConfiguration, diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/RpkLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/RpkLwM2MIntegrationTest.java index e94b8a6319..ce942090e9 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/RpkLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/RpkLwM2MIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -113,13 +113,13 @@ public class RpkLwM2MIntegrationTest extends AbstractSecurityLwM2MIntegrationTes RPKClientCredential clientCredentials = new RPKClientCredential(); clientCredentials.setEndpoint(clientEndpoint); clientCredentials.setKey(Base64.encodeBase64String(certificate.getPublicKey().getEncoded())); - Security securityBs = rpkBootstrap(SECURE_URI_BS, + Security securityRpkBs = rpkBootstrap(SECURE_URI_BS, certificate.getPublicKey().getEncoded(), privateKey.getEncoded(), serverX509CertBs.getPublicKey().getEncoded()); Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(RPK, BOTH)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, clientPrivateKeyFromCertTrust, certificate, RPK, false); - this.basicTestConnection(null, securityBs, + this.basicTestConnection(null, securityRpkBs, deviceCredentials, clientEndpoint, transportConfiguration, diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_NoTrustLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_NoTrustLwM2MIntegrationTest.java index 1ba408ac70..5b385dfea0 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_NoTrustLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_NoTrustLwM2MIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,15 +51,14 @@ public class X509_NoTrustLwM2MIntegrationTest extends AbstractSecurityLwM2MInteg X509ClientCredential clientCredentials = new X509ClientCredential(); clientCredentials.setEndpoint(clientEndpoint); clientCredentials.setCert(Base64.getEncoder().encodeToString(certificate.getEncoded())); - Security security = x509(SECURE_URI, + Security securityX509 = x509(SECURE_URI, shortServerId, certificate.getEncoded(), privateKey.getEncoded(), serverX509Cert.getEncoded()); Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(X509, NONE)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, privateKey, certificate, X509, false); - this.basicTestConnection(security, - null, + this.basicTestConnection(securityX509, null, deviceCredentials, clientEndpoint, transportConfiguration, @@ -119,8 +118,7 @@ public class X509_NoTrustLwM2MIntegrationTest extends AbstractSecurityLwM2MInteg serverX509CertBs.getEncoded()); Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(X509, BOTH)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, privateKey, certificate, X509, false); - this.basicTestConnection(security, - null, + this.basicTestConnection(security, null, deviceCredentials, clientEndpoint, transportConfiguration, diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_TrustLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_TrustLwM2MIntegrationTest.java index 81b708ba33..44eeb96d5f 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_TrustLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_TrustLwM2MIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -50,8 +50,7 @@ public class X509_TrustLwM2MIntegrationTest extends AbstractSecurityLwM2MIntegra serverX509Cert.getEncoded()); Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(X509, NONE)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, privateKey, certificate, X509, false); - this.basicTestConnection(security, - null, + this.basicTestConnection(security, null, deviceCredentials, clientEndpoint, transportConfiguration, @@ -77,8 +76,7 @@ public class X509_TrustLwM2MIntegrationTest extends AbstractSecurityLwM2MIntegra serverX509CertBs.getEncoded()); Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(X509, BOTH)); LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, privateKey, certificate, X509, false); - this.basicTestConnection(security, - null, + this.basicTestConnection(security,null, deviceCredentials, clientEndpoint, transportConfiguration, diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServerHelperTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServerHelperTest.java index 5e0a218185..a9d120981e 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServerHelperTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServerHelperTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/transportConfiguration/ObserveStrategyTransportConfigurationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/transportConfiguration/ObserveStrategyTransportConfigurationTest.java index 18614bbe7c..2f5c200b53 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/transportConfiguration/ObserveStrategyTransportConfigurationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/transportConfiguration/ObserveStrategyTransportConfigurationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/transportConfiguration/ObserveStrategyWithNoSecQueueModeConnectTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/transportConfiguration/ObserveStrategyWithNoSecQueueModeConnectTest.java index 46e10ce81b..10732613b4 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/transportConfiguration/ObserveStrategyWithNoSecQueueModeConnectTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/transportConfiguration/ObserveStrategyWithNoSecQueueModeConnectTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java index 81b587fdaa..364b8fbe6a 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java index d11c8c1c5e..c455ae45d4 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttTestConfigProperties.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttTestConfigProperties.java index 24111d0021..ee06d8d9ad 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttTestConfigProperties.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttTestConfigProperties.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/MqttTestCallback.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/MqttTestCallback.java index 949fea9a83..a9dbe98fee 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/MqttTestCallback.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/MqttTestCallback.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/MqttTestClient.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/MqttTestClient.java index 054ae52a19..7e5cc031a5 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/MqttTestClient.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/MqttTestClient.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/MqttTestSubscribeOnTopicCallback.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/MqttTestSubscribeOnTopicCallback.java index 737a9229db..8fc126fcaa 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/MqttTestSubscribeOnTopicCallback.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/MqttTestSubscribeOnTopicCallback.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java index 5c70f05b94..a7307b2308 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestBackwardCompatibilityIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestBackwardCompatibilityIntegrationTest.java index c27df10f89..0652219d75 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestBackwardCompatibilityIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestBackwardCompatibilityIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestIntegrationTest.java index b992cc4f98..00b1cb0160 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestJsonIntegrationTest.java index d409d0074f..3786a133ef 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestJsonIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestProtoIntegrationTest.java index 7aad73eea1..e368183459 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestProtoIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/updates/MqttAttributesUpdatesBackwardCompatibilityIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/updates/MqttAttributesUpdatesBackwardCompatibilityIntegrationTest.java index 112f088196..637127da39 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/updates/MqttAttributesUpdatesBackwardCompatibilityIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/updates/MqttAttributesUpdatesBackwardCompatibilityIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/updates/MqttAttributesUpdatesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/updates/MqttAttributesUpdatesIntegrationTest.java index d782f26d80..3b5e15b0f3 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/updates/MqttAttributesUpdatesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/updates/MqttAttributesUpdatesIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/updates/MqttAttributesUpdatesJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/updates/MqttAttributesUpdatesJsonIntegrationTest.java index 82b16ba28c..f8d6d63a62 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/updates/MqttAttributesUpdatesJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/updates/MqttAttributesUpdatesJsonIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/updates/MqttAttributesUpdatesProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/updates/MqttAttributesUpdatesProtoIntegrationTest.java index 433bfa0924..4ad8b59455 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/updates/MqttAttributesUpdatesProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/updates/MqttAttributesUpdatesProtoIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimBackwardCompatibilityDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimBackwardCompatibilityDeviceTest.java index 1d01f1af14..c5200d19af 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimBackwardCompatibilityDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimBackwardCompatibilityDeviceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimDeviceTest.java index 93c03904b1..03879a1b61 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimDeviceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimJsonDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimJsonDeviceTest.java index 6f2042f7fb..e2a675b75c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimJsonDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimJsonDeviceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimProtoDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimProtoDeviceTest.java index cb2f2c3cee..0114628370 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimProtoDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/claim/MqttClaimProtoDeviceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/client/AbstractMqttClientConnectionTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/client/AbstractMqttClientConnectionTest.java index d0ec3d59fc..dc607125d8 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/client/AbstractMqttClientConnectionTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/client/AbstractMqttClientConnectionTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/client/MqttClientConnectionTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/client/MqttClientConnectionTest.java index 2c91bc85f1..0430e10bfe 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/client/MqttClientConnectionTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/client/MqttClientConnectionTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/credentials/BasicMqttCredentialsTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/credentials/BasicMqttCredentialsTest.java index 623ce1359a..08dcc0b007 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/credentials/BasicMqttCredentialsTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/credentials/BasicMqttCredentialsTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionJsonDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionJsonDeviceTest.java index 5eb5aa1b73..8b3a2c833f 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionJsonDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionJsonDeviceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionProtoDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionProtoDeviceTest.java index 6b806e2af2..41a333ce65 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionProtoDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/provision/MqttProvisionProtoDeviceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/AbstractMqttServerSideRpcIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/AbstractMqttServerSideRpcIntegrationTest.java index 5fd16046ae..75d529d4b1 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/AbstractMqttServerSideRpcIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/AbstractMqttServerSideRpcIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttClientSideRpcIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttClientSideRpcIntegrationTest.java index 62fb27afb0..5b59aa1f27 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttClientSideRpcIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttClientSideRpcIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcBackwardCompatibilityIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcBackwardCompatibilityIntegrationTest.java index 7927e84171..4bf7251524 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcBackwardCompatibilityIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcBackwardCompatibilityIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcDefaultIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcDefaultIntegrationTest.java index 9f6756cc9a..8abef55b50 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcDefaultIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcDefaultIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcJsonIntegrationTest.java index b3315542b6..10ef549c87 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcJsonIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcProtoIntegrationTest.java index 928a201748..1318c8d33b 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcProtoIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcSequenceOnAckIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcSequenceOnAckIntegrationTest.java index 09e3738edb..2b33619881 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcSequenceOnAckIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcSequenceOnAckIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcSequenceOnResponseIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcSequenceOnResponseIntegrationTest.java index 514ffddac5..091e35edf2 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcSequenceOnResponseIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/rpc/MqttServerSideRpcSequenceOnResponseIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/attributes/MqttAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/attributes/MqttAttributesIntegrationTest.java index 79c277128d..89bf2caa73 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/attributes/MqttAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/attributes/MqttAttributesIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/attributes/MqttAttributesJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/attributes/MqttAttributesJsonIntegrationTest.java index d607faed31..c61df87874 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/attributes/MqttAttributesJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/attributes/MqttAttributesJsonIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/attributes/MqttAttributesProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/attributes/MqttAttributesProtoIntegrationTest.java index 23cc1a40c5..c20dd6e3ba 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/attributes/MqttAttributesProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/attributes/MqttAttributesProtoIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java index 9856aa18cb..ea40a574a5 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java index 92b35d896c..80478942e8 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java index 60e5857c04..133be37d36 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/nosql/MqttTimeseriesNoSqlIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/nosql/MqttTimeseriesNoSqlIntegrationTest.java index 59c1cba815..74a55ef10c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/nosql/MqttTimeseriesNoSqlIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/nosql/MqttTimeseriesNoSqlIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/nosql/MqttTimeseriesNoSqlJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/nosql/MqttTimeseriesNoSqlJsonIntegrationTest.java index badf632150..4473ee5676 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/nosql/MqttTimeseriesNoSqlJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/nosql/MqttTimeseriesNoSqlJsonIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/nosql/MqttTimeseriesNoSqlProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/nosql/MqttTimeseriesNoSqlProtoIntegrationTest.java index 9f562d4593..4c04a32c83 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/nosql/MqttTimeseriesNoSqlProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/nosql/MqttTimeseriesNoSqlProtoIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/sql/MqttTimeseriesSqlIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/sql/MqttTimeseriesSqlIntegrationTest.java index 9f8cb97c4a..ea7a85dda2 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/sql/MqttTimeseriesSqlIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/sql/MqttTimeseriesSqlIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/sql/MqttTimeseriesSqlJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/sql/MqttTimeseriesSqlJsonIntegrationTest.java index fde8bdd78f..ccf514b46e 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/sql/MqttTimeseriesSqlJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/sql/MqttTimeseriesSqlJsonIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/sql/MqttTimeseriesSqlProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/sql/MqttTimeseriesSqlProtoIntegrationTest.java index fd5b86caf7..62f07e5ec9 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/sql/MqttTimeseriesSqlProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/sql/MqttTimeseriesSqlProtoIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/AbstractMqttV5Test.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/AbstractMqttV5Test.java index 32c17f8ac2..ede39806ed 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/AbstractMqttV5Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/AbstractMqttV5Test.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/MqttV5TestCallback.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/MqttV5TestCallback.java index cdcc961961..c77a403e5c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/MqttV5TestCallback.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/MqttV5TestCallback.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/MqttV5TestClient.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/MqttV5TestClient.java index 0716bfbcee..545fc148e2 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/MqttV5TestClient.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/MqttV5TestClient.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/attributes/AbstractAttributesMqttV5Test.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/attributes/AbstractAttributesMqttV5Test.java index 25bcb41ace..6dcde311b4 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/attributes/AbstractAttributesMqttV5Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/attributes/AbstractAttributesMqttV5Test.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/attributes/updates/AttributesUpdatesTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/attributes/updates/AttributesUpdatesTest.java index 4dd2c85e81..15cbe55155 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/attributes/updates/AttributesUpdatesTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/attributes/updates/AttributesUpdatesTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/attributes/upload/AttributesPublishTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/attributes/upload/AttributesPublishTest.java index 5fc06e019c..966c842635 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/attributes/upload/AttributesPublishTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/attributes/upload/AttributesPublishTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/claim/AbstractMqttV5ClaimTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/claim/AbstractMqttV5ClaimTest.java index 67dee834eb..c90197ea5e 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/claim/AbstractMqttV5ClaimTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/claim/AbstractMqttV5ClaimTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/claim/MqttV5ClaimTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/claim/MqttV5ClaimTest.java index 870de711ff..bfec851ae8 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/claim/MqttV5ClaimTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/claim/MqttV5ClaimTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/connection/AbstractMqttV5ClientConnectionTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/connection/AbstractMqttV5ClientConnectionTest.java index 522475d396..0a15027e05 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/connection/AbstractMqttV5ClientConnectionTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/connection/AbstractMqttV5ClientConnectionTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/connection/MqttV5ClientConnectionTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/connection/MqttV5ClientConnectionTest.java index 4234943f77..f7e802f59c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/connection/MqttV5ClientConnectionTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/connection/MqttV5ClientConnectionTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/publish/AbstractMqttV5ClientPublishTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/publish/AbstractMqttV5ClientPublishTest.java index 4255df039d..50bca6f503 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/publish/AbstractMqttV5ClientPublishTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/publish/AbstractMqttV5ClientPublishTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/publish/MqttV5ClientPublishTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/publish/MqttV5ClientPublishTest.java index 502c63030b..ea06f03acd 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/publish/MqttV5ClientPublishTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/publish/MqttV5ClientPublishTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/subscribe/AbstractMqttV5ClientSubscriptionTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/subscribe/AbstractMqttV5ClientSubscriptionTest.java index cba0508f62..45968e87f2 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/subscribe/AbstractMqttV5ClientSubscriptionTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/subscribe/AbstractMqttV5ClientSubscriptionTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/subscribe/MqttV5ClientSubscriptionTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/subscribe/MqttV5ClientSubscriptionTest.java index 736f2f7834..4d54121d9f 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/subscribe/MqttV5ClientSubscriptionTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/subscribe/MqttV5ClientSubscriptionTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/unsubscribe/AbstractMqttV5ClientUnsubscribeTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/unsubscribe/AbstractMqttV5ClientUnsubscribeTest.java index 05df5a5c0f..d85cb248f2 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/unsubscribe/AbstractMqttV5ClientUnsubscribeTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/unsubscribe/AbstractMqttV5ClientUnsubscribeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/unsubscribe/MqttV5ClientUnsubscribeTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/unsubscribe/MqttV5ClientUnsubscribeTest.java index 61ea72380d..ae30dca3d1 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/unsubscribe/MqttV5ClientUnsubscribeTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/client/unsubscribe/MqttV5ClientUnsubscribeTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/provision/MqttV5ProvisionDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/provision/MqttV5ProvisionDeviceTest.java index 9aff549630..f6ce486584 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/provision/MqttV5ProvisionDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/provision/MqttV5ProvisionDeviceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/rpc/AbstractMqttV5RpcTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/rpc/AbstractMqttV5RpcTest.java index 750e722c85..aef7b661d5 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/rpc/AbstractMqttV5RpcTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/rpc/AbstractMqttV5RpcTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/rpc/MqttV5CloseTransportSessionOnRpcDeliveryTimeoutIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/rpc/MqttV5CloseTransportSessionOnRpcDeliveryTimeoutIntegrationTest.java index 09f716eba9..23aa5133c5 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/rpc/MqttV5CloseTransportSessionOnRpcDeliveryTimeoutIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/rpc/MqttV5CloseTransportSessionOnRpcDeliveryTimeoutIntegrationTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/rpc/MqttV5RpcTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/rpc/MqttV5RpcTest.java index 55e73ed4e4..bd205d0448 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/rpc/MqttV5RpcTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/rpc/MqttV5RpcTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/timeseries/AbstractMqttV5TimeseriesTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/timeseries/AbstractMqttV5TimeseriesTest.java index 8afcfa8015..780a73a966 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/timeseries/AbstractMqttV5TimeseriesTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/timeseries/AbstractMqttV5TimeseriesTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/timeseries/MqttV5TimeseriesTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/timeseries/MqttV5TimeseriesTest.java index f41eb3ae4c..7114f12253 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/timeseries/MqttV5TimeseriesTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/timeseries/MqttV5TimeseriesTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java index 13e9dcaf0e..6fb08f9eea 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java index e4e76e6761..756c8e603c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesInProfileTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesInProfileTest.java index 5f7d8e8933..37abade6e7 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesInProfileTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesInProfileTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java index cecad77c7a..3826b59bdc 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java index 9b22bd52d9..8f9943b393 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionTest.java index 03cb0a843e..01038c21f1 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/AbstractMqttV5RpcSparkplugTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/AbstractMqttV5RpcSparkplugTest.java index 96fc9c0a4c..49270d1dce 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/AbstractMqttV5RpcSparkplugTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/AbstractMqttV5RpcSparkplugTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java index eb6c613940..e36b4e6260 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java index 8368db546b..319b61b7f3 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/MqttV5ClientSparkplugBTelemetryTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/MqttV5ClientSparkplugBTelemetryTest.java index a4076ac32b..bafe2d81d2 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/MqttV5ClientSparkplugBTelemetryTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/MqttV5ClientSparkplugBTelemetryTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. 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..1e2273af7c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java @@ -0,0 +1,167 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.common.data.kv.StringDataEntry; +import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; +import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState; + +import java.util.LinkedHashMap; +import java.util.List; +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.mockito.Mockito.when; +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; +import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto; + +@ExtendWith(MockitoExtension.class) +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(DEVICE_ID); + + CalculatedFieldCtx cfCtxMock = mock(CalculatedFieldCtx.class); + when(cfCtxMock.getArgNames()).thenReturn(List.of("geofencingArgumentTest")); + + state.setCtx(cfCtxMock, null); + + Map updatedArguments = state.update(Map.of("geofencingArgumentTest", geofencingArgumentEntry), cfCtxMock); + assertThat(updatedArguments).hasSize(1); + assertThat(updatedArguments.get("geofencingArgumentTest")).isEqualTo(geofencingArgumentEntry); + + CalculatedFieldStateProto proto = toProto(stateId, state); + CalculatedFieldState fromProto = CalculatedFieldUtils.fromProto(stateId, proto); + + assertThat(fromProto) + .usingRecursiveComparison() + .ignoringFields("ctx", "requiredArguments", "readinessStatus", "latestTimestamp") + .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(); + } + + @Test + void toProtoAndFromProto_shouldCreatePropagationStateWithNotEmptyPropagationArgument() { + // given + CalculatedFieldEntityCtxId stateId = mock(CalculatedFieldEntityCtxId.class); + given(stateId.tenantId()).willReturn(TENANT_ID); + given(stateId.cfId()).willReturn(CF_ID); + given(stateId.entityId()).willReturn(DEVICE_ID); + + AssetId propagationAssetId = new AssetId(UUID.fromString("17bbf99c-3b87-4d21-b07d-da7409bb2bb7")); + PropagationArgumentEntry propagationArgumentEntry = new PropagationArgumentEntry(List.of(propagationAssetId)); + + long lastUpdateTs = System.currentTimeMillis(); + SingleValueArgumentEntry singleValueArgumentEntry = new SingleValueArgumentEntry(new BaseAttributeKvEntry(new StringDataEntry("state", "active"), lastUpdateTs, 1L)); + + CalculatedFieldCtx cfCtxMock = mock(CalculatedFieldCtx.class); + when(cfCtxMock.getArgNames()).thenReturn(List.of("state")); + + CalculatedFieldState state = new PropagationCalculatedFieldState(DEVICE_ID); + + state.setCtx(cfCtxMock, null); + + Map updatedArguments = state.update(Map.of(PROPAGATION_CONFIG_ARGUMENT, propagationArgumentEntry, "state", singleValueArgumentEntry), cfCtxMock); + assertThat(updatedArguments).hasSize(2); + assertThat(updatedArguments.get(PROPAGATION_CONFIG_ARGUMENT)).isEqualTo(propagationArgumentEntry); + assertThat(updatedArguments.get("state")).isEqualTo(singleValueArgumentEntry); + + // when + CalculatedFieldStateProto proto = toProto(stateId, state); + + // then + CalculatedFieldState restored = CalculatedFieldUtils.fromProto(stateId, proto); + + // Propagation argument is not persisted -> should be absent after restore + assertThat(restored).isNotNull(); + assertThat(restored).isInstanceOf(PropagationCalculatedFieldState.class); + + PropagationCalculatedFieldState propagationState = (PropagationCalculatedFieldState) restored; + + assertThat(propagationState.getEntityId()).isEqualTo(DEVICE_ID); + assertThat(propagationState.getArguments()).isNotNull(); + assertThat(propagationState.getArguments().get(PROPAGATION_CONFIG_ARGUMENT)).isEqualTo(propagationArgumentEntry); + assertThat(propagationState.getArguments().get("state")).isNotNull().isEqualTo(singleValueArgumentEntry); + assertThat(propagationState.getRequiredArguments()).isNull(); + assertThat(propagationState.getReadinessStatus()).isNull(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/utils/TbNodeUpgradeUtilsTest.java b/application/src/test/java/org/thingsboard/server/utils/TbNodeUpgradeUtilsTest.java index 77e91f1b47..8126afd6af 100644 --- a/application/src/test/java/org/thingsboard/server/utils/TbNodeUpgradeUtilsTest.java +++ b/application/src/test/java/org/thingsboard/server/utils/TbNodeUpgradeUtilsTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/build.sh b/build.sh index 22258fbd2e..d2a965f281 100755 --- a/build.sh +++ b/build.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Copyright © 2016-2025 The Thingsboard Authors +# Copyright © 2016-2026 The Thingsboard Authors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/build_proto.sh b/build_proto.sh index f0690e75bb..9228dcb3a2 100755 --- a/build_proto.sh +++ b/build_proto.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Copyright © 2016-2025 The Thingsboard Authors +# Copyright © 2016-2026 The Thingsboard Authors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/common/actor/pom.xml b/common/actor/pom.xml index 4149fe2ad9..bd441e3a58 100644 --- a/common/actor/pom.xml +++ b/common/actor/pom.xml @@ -1,6 +1,6 @@ - + @@ -34,5 +34,4 @@
- diff --git a/monitoring/src/main/resources/lwm2m/models/0.xml b/monitoring/src/main/resources/lwm2m/models/0.xml index cfe4e551c0..1614459802 100644 --- a/monitoring/src/main/resources/lwm2m/models/0.xml +++ b/monitoring/src/main/resources/lwm2m/models/0.xml @@ -1,7 +1,7 @@ +
+ +

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

+ +
+
+
+ + + +
+ +
+
+
+
+ + @if (!data.readonly) { + + } +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.ts new file mode 100644 index 0000000000..fc55602e35 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.ts @@ -0,0 +1,60 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormControl } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; + +export interface AlarmRuleDetailsDialogData { + alarmDetails: string; + readonly: boolean; +} + +@Component({ + selector: 'tb-edit-alarm-details-dialog', + templateUrl: './alarm-rule-details-dialog.component.html', + providers: [], + styleUrls: ['./cf-alarm-rules-dialog.component.scss'], +}) +export class AlarmRuleDetailsDialogComponent extends DialogComponent { + + alarmDetailsControl: FormControl; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AlarmRuleDetailsDialogData, + public dialogRef: MatDialogRef, + private fb: FormBuilder) { + super(store, router, dialogRef); + + this.alarmDetailsControl = this.fb.control(this.data.alarmDetails); + if (this.data.readonly) { + this.alarmDetailsControl.disable(); + } + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.dialogRef.close(this.alarmDetailsControl.value); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html new file mode 100644 index 0000000000..3b5379a9d9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html @@ -0,0 +1,173 @@ + +
+ +

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

+ +
+ +
+
+
+
+
{{ 'common.general' | translate }}
+
+ + {{ 'alarm-rule.alarm-type' | translate }} + + alarm-rule.alarm-type-hint + @if (fieldFormGroup.get('name').errors && fieldFormGroup.get('name').touched) { + + @if (fieldFormGroup.get('name').hasError('required')) { + {{ 'alarm-rule.alarm-type-required' | translate }} + } @else if (fieldFormGroup.get('name').hasError('pattern')) { + {{ 'alarm-rule.alarm-type-pattern' | translate }} + } @else if (fieldFormGroup.get('name').hasError('maxlength')) { + {{ 'alarm-rule.alarm-type-max-length' | translate }} + } + + } + + +
+ @if (!data.entityId) { + + + } +
+ +
+
{{ 'calculated-fields.arguments' | translate }}
+ +
+
+
{{ 'alarm-rule.create-conditions' | translate }}
+
+ + +
+
+
+
{{ 'alarm-rule.clear-condition' | translate }}
+
+
+ + +
+ +
+
+ alarm-rule.no-clear-alarm-rule +
+
+ +
+
+
+ + + {{ 'alarm-rule.advanced-settings' | translate }} + + +
+
+ + {{ 'alarm-rule.propagate-alarm' | translate }} + +
+ @if (configFormGroup.get('propagate').value) { + + + } +
+
+ + {{ 'alarm-rule.propagate-alarm-to-owner' | translate }} + +
+
+ + {{ 'alarm-rule.propagate-alarm-to-tenant' | translate }} + +
+
+
+
+
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.scss new file mode 100644 index 0000000000..fc9590f115 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.scss @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.calculated-field-dialog-container { + width: 869px; + max-width: 100%; + + .tb-disabled { + color: rgba(0, 0, 0, 0.38); + } +} + +.clear-alarm-rule { + border: 1px solid rgba(0, 0, 0, 0.12); + border-left-width: 4px; + border-left-color: green; + border-radius: 4px; + padding: 8px; + min-width: 0; +} + +.button-icon { + color: rgba(0, 0, 0, 0.38); + min-width: 40px; +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts new file mode 100644 index 0000000000..f054616f20 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts @@ -0,0 +1,238 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, Inject, ViewChild, ViewEncapsulation } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormGroup } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { CalculatedField, CalculatedFieldArgument, CalculatedFieldType } from '@shared/models/calculated-field.models'; +import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ScriptLanguage } from '@shared/models/rule-node.models'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { EntityId } from '@shared/models/id/entity-id'; +import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; +import { COMMA, ENTER, SEMICOLON } from "@angular/cdk/keycodes"; +import { + AlarmRule, + AlarmRuleConditionType, + alarmRuleEntityTypeList, + AlarmRuleExpressionType, + AlarmRuleTestScriptFn +} from "@shared/models/alarm-rule.models"; +import { deepTrim } from "@core/utils"; +import { combineLatest, Observable } from "rxjs"; +import { debounceTime, startWith } from "rxjs/operators"; +import { RelationTypes } from "@shared/models/relation.models"; +import { StringItemsOption } from "@shared/components/string-items-list.component"; +import { BaseData } from "@shared/models/base-data"; +import { CalculatedFieldFormService } from '@core/services/calculated-field-form.service'; +import { EntitySelectComponent } from '@shared/components/entity/entity-select.component'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { AssetInfo } from '@shared/models/asset.models'; +import { DeviceInfo } from '@shared/models/device.models'; + +export interface AlarmRuleDialogData { + value?: CalculatedField; + buttonTitle: string; + entityId: EntityId; + tenantId: string; + entityName?: string; + ownerId: EntityId; + additionalDebugActionConfig: AdditionalDebugActionConfig<(calculatedField: CalculatedField) => void>; + isDirty?: boolean; + getTestScriptDialogFn: AlarmRuleTestScriptFn, +} + +@Component({ + selector: 'tb-alarm-rule-dialog', + templateUrl: './alarm-rule-dialog.component.html', + styleUrls: ['./alarm-rule-dialog.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class AlarmRuleDialogComponent extends DialogComponent { + + @ViewChild('entitySelect') entitySelect!: EntitySelectComponent; + + fieldFormGroup: FormGroup ; + + additionalDebugActionConfig = this.data.value?.id ? { + ...this.data.additionalDebugActionConfig, + action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }), + } : null; + + readonly EntityType = EntityType; + readonly entityTypeTranslations = entityTypeTranslations; + readonly alarmRuleEntityTypeList = alarmRuleEntityTypeList; + readonly CalculatedFieldType = CalculatedFieldType; + readonly ScriptLanguage = ScriptLanguage; + + separatorKeysCodes = [ENTER, COMMA, SEMICOLON]; + + entityName = this.data.entityName; + ownerId = this.data.ownerId; + + disabledClearRuleButton = false; + disabledArguments = false; + isLoading = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AlarmRuleDialogData, + protected dialogRef: MatDialogRef, + private calculatedFieldsService: CalculatedFieldsService, + private destroyRef: DestroyRef, + private cfFormService: CalculatedFieldFormService) { + super(store, router, dialogRef); + this.fieldFormGroup = this.cfFormService.buildAlarmRuleForm(); + this.applyDialogData(); + this.updateRulesValidators(); + + if (this.data.isDirty) { + this.fieldFormGroup.markAsDirty(); + } + + this.fieldFormGroup.get('configuration.arguments').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateRulesValidators(); + }); + + if (!this.data.entityId) { + combineLatest([ + this.fieldFormGroup.get('entityId')!.valueChanges.pipe(startWith(this.fieldFormGroup.get('entityId')!.value)), + this.fieldFormGroup.get('name')!.valueChanges.pipe(startWith(this.fieldFormGroup.get('name')!.value)) + ]).pipe( + debounceTime(50), + takeUntilDestroyed() + ).subscribe(([entityId, name]) => { + this.disabledArguments = !entityId || !name?.length; + const argsControl = this.fieldFormGroup.get('configuration.arguments')!; + if (this.disabledArguments) { + argsControl.disable({ emitEvent: false }); + } else { + argsControl.enable({ emitEvent: false }); + } + }); + } + } + + get configFormGroup(): FormGroup { + return this.fieldFormGroup.get('configuration') as FormGroup; + } + + get arguments(): Record { + return this.fieldFormGroup.get('configuration.arguments').value; + } + + public removeClearAlarmRule() { + this.configFormGroup.patchValue({clearRule: null}); + this.fieldFormGroup.markAsDirty(); + } + + public addClearAlarmRule() { + const clearAlarmRule: AlarmRule = { + condition: { + type: AlarmRuleConditionType.SIMPLE, + expression: { + type: AlarmRuleExpressionType.SIMPLE + } + } + }; + this.configFormGroup.patchValue({clearRule: clearAlarmRule}); + } + + get fromGroupValue(): CalculatedField { + return deepTrim(this.fieldFormGroup.value as CalculatedField); + } + + cancel(): void { + this.dialogRef.close(null); + } + + add(): void { + if (this.fieldFormGroup.valid && Object.keys(this.arguments ?? {}).length > 0) { + this.isLoading = true; + const alarmRule = { entityId: this.data.entityId, ...(this.data.value ?? {}), ...this.fromGroupValue}; + alarmRule.configuration.type = CalculatedFieldType.ALARM; + + this.calculatedFieldsService.saveCalculatedField(alarmRule) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: calculatedField => this.dialogRef.close(calculatedField), + error: () => this.isLoading = false + }); + } else { + this.fieldFormGroup.get('name').markAsTouched(); + this.entitySelect.entityAutocompleteMarkAsTouched(); + } + } + + private applyDialogData(): void { + const { configuration = {}, type = CalculatedFieldType.ALARM, debugSettings = { failuresEnabled: true, allEnabled: true }, entityId = this.data.entityId, ...value } = this.data.value ?? {}; + this.fieldFormGroup.patchValue({ configuration, type, debugSettings, entityId, ...value }, {emitEvent: false}); + } + + onTestScript(expression: string): Observable { + return this.cfFormService.testScript( + this.data.value?.id?.id, + this.fromGroupValue, + this.data.getTestScriptDialogFn, + this.destroyRef, + expression + ); + } + + private updateRulesValidators(): void { + if (Object.keys(this.arguments ?? {}).length > 0) { + this.fieldFormGroup.get('configuration.createRules').enable({emitEvent: false}); + this.fieldFormGroup.get('configuration.clearRule').enable({emitEvent: false}); + this.fieldFormGroup.get('configuration.propagate').enable({emitEvent: false}); + this.fieldFormGroup.get('configuration.propagateToOwner').enable({emitEvent: false}); + this.fieldFormGroup.get('configuration.propagateToTenant').enable({emitEvent: false}); + this.fieldFormGroup.get('configuration.propagateRelationTypes').enable({emitEvent: false}); + this.disabledClearRuleButton = false; + } else { + this.fieldFormGroup.get('configuration.createRules').disable({emitEvent: false}); + this.fieldFormGroup.get('configuration.clearRule').disable({emitEvent: false}); + this.fieldFormGroup.get('configuration.propagate').disable({emitEvent: false}); + this.fieldFormGroup.get('configuration.propagateToOwner').disable({emitEvent: false}); + this.fieldFormGroup.get('configuration.propagateToTenant').disable({emitEvent: false}); + this.fieldFormGroup.get('configuration.propagateRelationTypes').disable({emitEvent: false}); + this.disabledClearRuleButton = true; + } + } + get predefinedTypeValues(): StringItemsOption[] { + return RelationTypes.map(type => ({ + name: type, + value: type + })); + } + + changeEntity(entity: BaseData): void { + this.entityName = entity.name; + if (this.isAssignedToCustomer(entity as AssetInfo | DeviceInfo)) { + this.ownerId = (entity as AssetInfo | DeviceInfo).customerId; + } + } + + private isAssignedToCustomer(entity: AssetInfo | DeviceInfo): boolean { + return entity && entity.customerId && entity.customerId.id !== NULL_UUID; + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.html new file mode 100644 index 0000000000..1382bf014b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.html @@ -0,0 +1,94 @@ + + + + + + + + + + +
+ +
+ + + + +
+
+
+ +
+
+
alarm-rule.alarm-rule-type-list
+ + +
+
+
entity.entity-type
+ + + {{ 'alarm-rule.any-type' | translate }} + + {{ entityTypeTranslations.get(type)?.type | translate }} + + + +
+ @if (alarmRuleFilterConfigForm.get('entityType').value) { +
+
entity.entities
+ + +
+ } +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.scss new file mode 100644 index 0000000000..c34e56296f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.scss @@ -0,0 +1,56 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../scss/constants'; + +:host { + display: flex; + max-width: 100%; + .mdc-button { + max-width: 100%; + } +} + +:host ::ng-deep { + .mdc-button { + .mat-icon { + min-width: 24px; + } + .mdc-button__label { + overflow: hidden; + text-overflow: ellipsis; + } + } +} + +::ng-deep { + .tb-alarm-filter-config-component { + max-width: 100%; + width: 600px; + min-width: 100%; + flex: 1; + + + tb-entity-subtype-list { + flex: 1; + @media #{$mat-gt-xs} { + width: 180px; + } + .mdc-evolution-chip-set__chips { + width: 100%; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts new file mode 100644 index 0000000000..daf0408182 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts @@ -0,0 +1,291 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + DestroyRef, + ElementRef, + forwardRef, + Inject, + InjectionToken, + Input, + OnInit, + Optional, + TemplateRef, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { TemplatePortal } from '@angular/cdk/portal'; +import { TranslateService } from '@ngx-translate/core'; +import { deepClone, isArraysEqualIgnoreUndefined, isDefinedAndNotNull, isEmpty, isUndefinedOrNull } from '@core/utils'; +import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { fromEvent, Subscription } from 'rxjs'; +import { POSITION_MAP } from '@shared/models/overlay.models'; +import { UtilsService } from '@core/services/utils.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { alarmRuleEntityTypeList, AlarmRuleFilterConfig } from "@shared/models/alarm-rule.models"; + +export const ALARM_FILTER_CONFIG_DATA = new InjectionToken('AlarmRuleFilterConfigData'); + +export interface AlarmRuleFilterConfigData { + panelMode: boolean; + userMode: boolean; + alarmRuleFilterConfig: AlarmRuleFilterConfig; + initialAlarmRuleFilterConfig?: AlarmRuleFilterConfig; +} + +@Component({ + selector: 'tb-alarm-rule-filter-config', + templateUrl: './alarm-rule-filter-config.component.html', + styleUrls: ['./alarm-rule-filter-config.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterConfigComponent), + multi: true + } + ] +}) +export class AlarmRuleFilterConfigComponent implements OnInit, ControlValueAccessor { + + @ViewChild('alarmRuleFilterPanel') + alarmRuleFilterPanel: TemplateRef; + + @Input() disabled: boolean; + + @coerceBoolean() + @Input() + buttonMode = true; + + @Input() + initialAlarmRuleFilterConfig: AlarmRuleFilterConfig = { + name: [], + entityType: null, + entities: [] + }; + + panelMode = false; + + buttonDisplayValue = this.translate.instant('alarm-rule.alarm-rule-filter-title'); + + alarmRuleFilterConfigForm: FormGroup; + + alarmFilterOverlayRef: OverlayRef; + + panelResult: AlarmRuleFilterConfig = null; + + entityType = EntityType; + + listEntityTypes = alarmRuleEntityTypeList; + entityTypeTranslations = entityTypeTranslations; + + private alarmRuleFilterConfig: AlarmRuleFilterConfig; + private resizeWindows: Subscription; + + private propagateChange = (_: any) => {}; + + constructor(@Optional() @Inject(ALARM_FILTER_CONFIG_DATA) + private data: AlarmRuleFilterConfigData | undefined, + @Optional() + private overlayRef: OverlayRef, + private fb: FormBuilder, + private translate: TranslateService, + private overlay: Overlay, + private nativeElement: ElementRef, + private viewContainerRef: ViewContainerRef, + private utils: UtilsService, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + if (this.data) { + this.panelMode = this.data.panelMode; + this.alarmRuleFilterConfig = this.data.alarmRuleFilterConfig; + this.initialAlarmRuleFilterConfig = this.data.initialAlarmRuleFilterConfig; + if (this.panelMode && !this.initialAlarmRuleFilterConfig) { + this.initialAlarmRuleFilterConfig = deepClone(this.alarmRuleFilterConfig); + } + } + this.alarmRuleFilterConfigForm = this.fb.group({ + name: [null, []], + entityType: [null, []], + entities: [null, []] + }); + this.alarmRuleFilterConfigForm.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => { + if (!this.buttonMode) { + this.alarmRuleConfigUpdated(this.alarmRuleFilterConfigForm.value); + } + } + ); + if (this.panelMode) { + this.updateAlarmRuleConfigForm(this.alarmRuleFilterConfig); + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.alarmRuleFilterConfigForm.disable({emitEvent: false}); + } else { + this.alarmRuleFilterConfigForm.enable({emitEvent: false}); + } + } + + writeValue(alarmRuleFilterConfig?: AlarmRuleFilterConfig): void { + this.alarmRuleFilterConfig = alarmRuleFilterConfig; + if (!this.initialAlarmRuleFilterConfig && alarmRuleFilterConfig) { + this.initialAlarmRuleFilterConfig = deepClone(alarmRuleFilterConfig); + } + this.updateButtonDisplayValue(); + this.updateAlarmRuleConfigForm(alarmRuleFilterConfig); + } + + toggleAlarmRuleFilterPanel($event: Event) { + if ($event) { + $event.stopPropagation(); + } + const config = new OverlayConfig({ + panelClass: 'tb-filter-panel', + backdropClass: 'cdk-overlay-transparent-backdrop', + hasBackdrop: true, + maxHeight: '80vh', + height: 'min-content', + minWidth: '' + }); + config.hasBackdrop = true; + config.positionStrategy = this.overlay.position() + .flexibleConnectedTo(this.nativeElement) + .withPositions([POSITION_MAP.bottomLeft]); + + this.alarmFilterOverlayRef = this.overlay.create(config); + this.alarmFilterOverlayRef.backdropClick().subscribe(() => { + this.alarmFilterOverlayRef.dispose(); + }); + this.alarmFilterOverlayRef.attach(new TemplatePortal(this.alarmRuleFilterPanel, + this.viewContainerRef)); + this.resizeWindows = fromEvent(window, 'resize').subscribe(() => { + this.alarmFilterOverlayRef.updatePosition(); + }); + } + + cancel() { + this.updateAlarmRuleConfigForm(this.alarmRuleFilterConfig); + this.alarmRuleFilterConfigForm.markAsPristine(); + if (this.overlayRef) { + this.overlayRef.dispose(); + } else { + this.resizeWindows.unsubscribe(); + this.alarmFilterOverlayRef.dispose(); + } + } + + update() { + this.alarmRuleConfigUpdated(this.alarmRuleFilterConfigForm.value); + this.alarmRuleFilterConfigForm.markAsPristine(); + if (this.panelMode) { + this.panelResult = this.alarmRuleFilterConfig; + } + if (this.overlayRef) { + this.overlayRef.dispose(); + } else { + this.resizeWindows.unsubscribe(); + this.alarmFilterOverlayRef.dispose(); + } + } + + reset() { + const alarmRuleFilterConfig = this.alarmRuleFilterConfigFromFormValue(this.alarmRuleFilterConfigForm.value); + if (!this.alarmRuleFilterConfigEquals(alarmRuleFilterConfig, this.initialAlarmRuleFilterConfig)) { + this.updateAlarmRuleConfigForm(this.initialAlarmRuleFilterConfig); + this.alarmRuleFilterConfigForm.markAsDirty(); + } + } + + private alarmRuleFilterConfigEquals = (filter1?: AlarmRuleFilterConfig, filter2?: AlarmRuleFilterConfig): boolean => { + if (filter1 === filter2) { + return true; + } + if ((isUndefinedOrNull(filter1) || isEmpty(filter1)) && (isUndefinedOrNull(filter2) || isEmpty(filter2))) { + return true; + } else if (isDefinedAndNotNull(filter1) && isDefinedAndNotNull(filter2)) { + if (!isArraysEqualIgnoreUndefined(filter1.name, filter2.name)) { + return false; + } + if (!isArraysEqualIgnoreUndefined(filter1.entities, filter2.entities)) { + return false; + } + return filter1.entityType === filter2.entityType; + } + return false; + }; + + private updateAlarmRuleConfigForm(alarmRuleFilterConfig?: AlarmRuleFilterConfig) { + this.alarmRuleFilterConfigForm.patchValue({ + name: alarmRuleFilterConfig?.name ?? [], + entityType: alarmRuleFilterConfig?.entityType ?? null, + entities: alarmRuleFilterConfig?.entities ?? [], + }, {emitEvent: false}); + } + + private alarmRuleConfigUpdated(formValue: any) { + this.alarmRuleFilterConfig = this.alarmRuleFilterConfigFromFormValue(formValue); + this.updateButtonDisplayValue(); + this.propagateChange(this.alarmRuleFilterConfig); + } + + private alarmRuleFilterConfigFromFormValue(formValue: any): AlarmRuleFilterConfig { + return { + name: formValue?.name ?? [], + entityType: formValue?.entityType ?? null, + entities: formValue?.entities ?? [], + }; + } + + private updateButtonDisplayValue() { + if (this.buttonMode) { + const filterTextParts: string[] = []; + if (this.alarmRuleFilterConfig?.name?.length) { + filterTextParts.push(this.alarmRuleFilterConfig.name.map((type) => this.customTranslate(type)).join(', ')); + } + if (this.alarmRuleFilterConfig?.entityType) { + filterTextParts.push(this.translate.instant( entityTypeTranslations.get(this.alarmRuleFilterConfig.entityType).type)); + } + if (!filterTextParts.length) { + this.buttonDisplayValue = this.translate.instant('alarm-rule.alarm-rule-filter-title'); + } else { + this.buttonDisplayValue = this.translate.instant('alarm-rule.filter-title') + `: ${filterTextParts.join(', ')}`; + } + } + } + + private customTranslate(entity: string) { + return this.utils.customTranslation(entity, entity); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.html new file mode 100644 index 0000000000..3c357b842d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.html @@ -0,0 +1,20 @@ + + + diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.scss new file mode 100644 index 0000000000..c6571e8450 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.scss @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + padding-right: 8px; + overflow: hidden; + max-width: 100%; +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts new file mode 100644 index 0000000000..95781decdb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts @@ -0,0 +1,43 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTableHeaderComponent } from '../../components/entity/entity-table-header.component'; +import { CalculatedFieldAlarmRule, CalculatedFieldsQuery } from "@shared/models/calculated-field.models"; +import { AlarmRulesTableConfig } from "@home/components/alarm-rules/alarm-rules-table-config"; + +@Component({ + selector: 'tb-alarm-rule-table-header', + templateUrl: './alarm-rule-table-header.component.html', + styleUrls: ['./alarm-rule-table-header.component.scss'] +}) +export class AlarmRuleTableHeaderComponent extends EntityTableHeaderComponent { + + get alarmRuleTableConfig(): AlarmRulesTableConfig { + return this.entitiesTableConfig as AlarmRulesTableConfig; + } + + constructor(protected store: Store) { + super(store); + } + + alarmRuleFilterChanged(alarmRuleFilterConfig: CalculatedFieldsQuery) { + this.alarmRuleTableConfig.alarmRuleFilterConfig = alarmRuleFilterConfig; + this.alarmRuleTableConfig.getTable().resetSortAndFilter(true, true); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts new file mode 100644 index 0000000000..858b23f3dd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts @@ -0,0 +1,91 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { AlarmRuleDialogComponent } from "@home/components/alarm-rules/alarm-rule-dialog.component"; +import { CreateCfAlarmRulesComponent } from "@home/components/alarm-rules/create-cf-alarm-rules.component"; +import { CfAlarmRuleComponent } from "@home/components/alarm-rules/cf-alarm-rule.component"; +import { CfAlarmRuleConditionComponent } from "@home/components/alarm-rules/cf-alarm-rule-condition.component"; +import { + CfAlarmRuleConditionDialogComponent +} from "@home/components/alarm-rules/cf-alarm-rule-condition-dialog.component"; +import { CfAlarmScheduleComponent } from "@home/components/alarm-rules/cf-alarm-schedule.component"; +import { CfAlarmScheduleDialogComponent } from "@home/components/alarm-rules/cf-alarm-schedule-dialog.component"; +import { + EntityDebugSettingsButtonComponent +} from "@home/components/entity/debug/entity-debug-settings-button.component"; +import { AlarmRuleFilterTextComponent } from "@home/components/alarm-rules/filter/alarm-rule-filter-text.component"; +import { + CalculatedFieldArgumentsTableModule +} from "@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module"; +import { + AlarmRuleFilterPredicateListComponent +} from "@home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component"; +import { + AlarmRuleFilterPredicateComponent +} from "@home/components/alarm-rules/filter/alarm-rule-filter-predicate.component"; +import { + AlarmRuleFilterPredicateValueComponent +} from "@home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component"; +import { + AlarmRuleComplexFilterPredicateDialogComponent +} from "@home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component"; +import { AlarmRuleFilterListComponent } from "@home/components/alarm-rules/filter/alarm-rule-filter-list.component"; +import { AlarmRuleFilterDialogComponent } from "@home/components/alarm-rules/filter/alarm-rule-filter-dialog.component"; +import { AlarmRuleDetailsDialogComponent } from "@home/components/alarm-rules/alarm-rule-details-dialog.component"; +import { AlarmRuleFilterConfigComponent } from "@home/components/alarm-rules/alarm-rule-filter-config.component"; +import { AlarmRuleTableHeaderComponent } from "@home/components/alarm-rules/alarm-rule-table-header.component"; +import { + AlarmRuleFilterPredicateNoDataValueComponent +} from "@home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component"; +import { AlarmRulesComponent } from '@home/components/alarm-rules/alarm-rules.component'; + +@NgModule({ + declarations: [ + AlarmRuleDialogComponent, + CreateCfAlarmRulesComponent, + CfAlarmRuleComponent, + CfAlarmRuleConditionComponent, + CfAlarmRuleConditionDialogComponent, + CfAlarmScheduleComponent, + CfAlarmScheduleDialogComponent, + AlarmRuleFilterTextComponent, + AlarmRuleFilterListComponent, + AlarmRuleFilterDialogComponent, + AlarmRuleFilterPredicateListComponent, + AlarmRuleFilterPredicateComponent, + AlarmRuleFilterPredicateValueComponent, + AlarmRuleComplexFilterPredicateDialogComponent, + AlarmRuleDetailsDialogComponent, + AlarmRuleFilterConfigComponent, + AlarmRuleTableHeaderComponent, + AlarmRuleFilterPredicateNoDataValueComponent, + AlarmRulesComponent + ], + imports: [ + CommonModule, + SharedModule, + EntityDebugSettingsButtonComponent, + CalculatedFieldArgumentsTableModule + ], + exports: [ + AlarmRuleDialogComponent, + AlarmRulesComponent + ] +}) +export class AlarmRuleModule { } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts new file mode 100644 index 0000000000..cadac572c6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts @@ -0,0 +1,437 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + checkBoxCell, + DateEntityTableColumn, + EntityLinkTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { TranslateService } from '@ngx-translate/core'; +import { Direction } from '@shared/models/page/sort-order'; +import { MatDialog } from '@angular/material/dialog'; +import { PageLink } from '@shared/models/page/page-link'; +import { EMPTY, Observable, of } from 'rxjs'; +import { PageData } from '@shared/models/page/page-data'; +import { EntityId } from '@shared/models/id/entity-id'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { DestroyRef, Renderer2 } from '@angular/core'; +import { EntityDebugSettings } from '@shared/models/entity.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { catchError, filter, first, switchMap, tap } from 'rxjs/operators'; +import { + ArgumentEntityType, + ArgumentType, + CalculatedField, + CalculatedFieldAlarmRule, + CalculatedFieldEventArguments, + CalculatedFieldInfo, + CalculatedFieldsQuery, + CalculatedFieldType, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights, +} from '@shared/models/calculated-field.models'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; +import { DatePipe } from '@angular/common'; +import { + AlarmRuleDialogComponent, + AlarmRuleDialogData +} from "@home/components/alarm-rules/alarm-rule-dialog.component"; +import { AlarmSeverity, alarmSeverityTranslations } from "@shared/models/alarm.models"; +import { UtilsService } from "@core/services/utils.service"; +import { deepClone, getEntityDetailsPageURL, isObject } from "@core/utils"; +import { AlarmRuleTableHeaderComponent } from "@home/components/alarm-rules/alarm-rule-table-header.component"; +import { EventsDialogComponent, EventsDialogData } from '@home/dialogs/events-dialog.component'; +import { DebugEventType, EventType } from '@shared/models/event.models'; +import { ActionNotificationShow } from "@core/notification/notification.actions"; +import { + CalculatedFieldScriptTestDialogComponent, + CalculatedFieldTestScriptDialogData +} from "@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component"; +import { AlarmRulesTabsComponent } from '@home/pages/alarm/alarm-rules-tabs.component'; +import { Router } from '@angular/router'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { AlarmRulesComponent } from '@home/components/alarm-rules/alarm-rules.component'; + +type AlarmRuleTableEntity = CalculatedField | CalculatedFieldInfo; + +export class AlarmRulesTableConfig extends EntityTableConfig { + + readonly tenantId = getCurrentAuthUser(this.store).tenantId; + additionalDebugActionConfig = { + title: this.translate.instant('action.see-debug-events'), + action: (calculatedField: AlarmRuleTableEntity) => this.openDebugEventsDialog.call(this, null, calculatedField), + }; + + alarmRuleFilterConfig: CalculatedFieldsQuery; + + constructor(private calculatedFieldsService: CalculatedFieldsService, + private translate: TranslateService, + private dialog: MatDialog, + private datePipe: DatePipe, + private entityId: EntityId = null, + private store: Store, + private destroyRef: DestroyRef, + private renderer: Renderer2, + private entityName: string, + private ownerId: EntityId = null, + private importExportService: ImportExportService, + private entityDebugSettingsService: EntityDebugSettingsService, + private utilsService: UtilsService, + private router: Router, + public pageMode: boolean = false, + ) { + super(); + if (this.pageMode) { + this.headerComponent = AlarmRuleTableHeaderComponent; + this.entityComponent = AlarmRulesComponent; + this.entityTabsComponent = AlarmRulesTabsComponent; + this.rowPointer = true; + } + this.tableTitle = this.pageMode ? '' : this.translate.instant('alarm-rule.alarm-rules'); + this.detailsPanelEnabled = this.pageMode; + this.entityResources = { + helpLinkId: 'alarmRules' + }; + this.entityType = EntityType.CALCULATED_FIELD; + this.entityTranslations = { + type: 'alarm-rule.alarm-rule', + typePlural: 'alarm-rule.alarm-rules', + list: 'alarm-rule.list', + add: 'action.add', + details: 'alarm-rule.details', + noEntities: 'alarm-rule.no-found', + search: 'action.search', + selectedEntities: 'alarm-rule.selected-fields' + }; + + this.entitiesFetchFunction = (pageLink: PageLink) => this.fetchCalculatedFields(pageLink); + this.addEntity = this.getCalculatedAlarmDialog.bind(this); + this.loadEntity = id => this.calculatedFieldsService.getCalculatedFieldById(id.id); + this.saveEntity = (alarmRule) => this.calculatedFieldsService.saveCalculatedField(alarmRule); + + this.deleteEntityTitle = (field) => this.translate.instant('alarm-rule.delete-title', {title: field.name}); + this.deleteEntityContent = () => this.translate.instant('alarm-rule.delete-text'); + this.deleteEntitiesTitle = count => this.translate.instant('alarm-rule.delete-multiple-title', {count}); + this.deleteEntitiesContent = () => this.translate.instant('alarm-rule.delete-multiple-text'); + this.deleteEntity = id => this.calculatedFieldsService.deleteCalculatedField(id.id); + + this.onEntityAction = action => this.onCFAction(action); + + this.addActionDescriptors = [ + { + name: this.translate.instant('alarm-rule.create'), + icon: 'insert_drive_file', + isEnabled: () => true, + onAction: ($event) => this.getTable().addEntity($event) + }, + { + name: this.translate.instant('alarm-rule.import'), + icon: 'file_upload', + isEnabled: () => true, + onAction: () => this.importCalculatedField() + } + ]; + + this.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; + this.columns.push(new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px')); + this.columns.push(new EntityTableColumn('name', 'alarm-rule.alarm-type', '33%', + entity => this.utilsService.customTranslation(entity.name, entity.name))); + if (this.pageMode) { + this.columns.push(new EntityTableColumn('entityType', 'entity.entity-type', '10%', + entity => this.translate.instant(entityTypeTranslations.get(entity.entityId.entityType).type))); + this.columns.push(new EntityLinkTableColumn('entityName', 'entity.entity', '33%', + entity => this.utilsService.customTranslation(entity.entityName, entity.entityName), + entity => getEntityDetailsPageURL(entity.entityId?.id, entity.entityId?.entityType as EntityType), false)); + } + this.columns.push(new EntityTableColumn('createRule', 'alarm-rule.severities', this.pageMode ? '23%' : '67%', + entity => Object.keys(entity.configuration.createRules).map((severity) => this.translate.instant(alarmSeverityTranslations.get(severity as AlarmSeverity))).join(', '), + () => ({}), false)); + this.columns.push(new EntityTableColumn('clearRule', 'alarm-rule.cleared', '90px', + entity => checkBoxCell(!!entity.configuration.clearRule), ()=> { return {padding: 0, textAlign: 'center'}}, false)); + + this.cellActionDescriptors.push( + { + name: this.translate.instant('alarm-rule.copy'), + icon: 'content_copy', + isEnabled: () => true, + onAction: ($event, entity) => this.copyCalculatedField($event, entity) + } + ); + this.cellActionDescriptors.push( + { + name: this.translate.instant('action.export'), + icon: 'file_download', + isEnabled: () => true, + onAction: ($event, entity) => this.exportAlarmRule($event, entity), + }, + { + name: this.translate.instant('entity-view.events'), + icon: 'mdi:clipboard-text-clock', + isEnabled: () => true, + onAction: ($event, entity) => + this.pageMode ? this.openDebugTab($event, entity) : this.openDebugEventsDialog($event, entity), + }, + { + name: '', + nameFunction: entity => this.entityDebugSettingsService.getDebugConfigLabel(entity?.debugSettings), + icon: 'mdi:bug', + isEnabled: () => true, + iconFunction: ({ debugSettings }) => this.entityDebugSettingsService.isDebugActive(debugSettings?.allEnabledUntil) || debugSettings?.failuresEnabled ? 'mdi:bug' : 'mdi:bug-outline', + onAction: ($event, entity) => this.onOpenDebugConfig($event, entity), + } + ); + if (!this.pageMode) { + this.cellActionDescriptors.push( + { + name: this.translate.instant('action.edit'), + icon: 'edit', + isEnabled: () => true, + onAction: ($event, entity) => this.editCalculatedField($event, entity), + } + ) + } + } + + fetchCalculatedFields(pageLink: PageLink): Observable> { + return this.pageMode ? + this.calculatedFieldsService.getCalculatedFields(pageLink, {types: [CalculatedFieldType.ALARM], ...this.alarmRuleFilterConfig}) : + this.calculatedFieldsService.getCalculatedFieldsByEntityId(this.entityId, pageLink, CalculatedFieldType.ALARM); + } + + onOpenDebugConfig($event: Event, calculatedField: AlarmRuleTableEntity): void { + $event?.stopPropagation(); + const { debugSettings = {}, id } = calculatedField; + const additionalActionConfig = { + ...this.additionalDebugActionConfig, + action: () => this.openDebugEventsDialog($event, calculatedField) + }; + + const { viewContainerRef, renderer } = this.entityDebugSettingsService; + if (!viewContainerRef || !renderer) { + this.entityDebugSettingsService.viewContainerRef = this.getTable().viewContainerRef; + this.entityDebugSettingsService.renderer = this.renderer; + } + + this.entityDebugSettingsService.openDebugStrategyPanel({ + debugSettings, + debugConfig: { + entityType: EntityType.CALCULATED_FIELD, + entityLabel: 'alarm-rule.alarm-rule', + additionalActionConfig, + }, + onSettingsAppliedFn: settings => this.onDebugConfigChanged(id.id, settings) + }, $event.target as Element); + } + + private editCalculatedField($event: Event, calculatedField: AlarmRuleTableEntity, isDirty = false): void { + $event?.stopPropagation(); + this.getCalculatedAlarmDialog(calculatedField, 'action.apply', isDirty) + .subscribe((res) => { + if (res) { + this.updateData(); + } + }); + } + + private copyCalculatedField($event: Event, calculatedField: AlarmRuleTableEntity, isDirty = false): void { + $event?.stopPropagation(); + const copyCalculatedAlarmRule = deepClone(calculatedField); + if (this.pageMode) { + copyCalculatedAlarmRule.entityId = null; + delete (copyCalculatedAlarmRule as CalculatedFieldInfo).entityName; + } + delete copyCalculatedAlarmRule.id; + this.getCalculatedAlarmDialog(copyCalculatedAlarmRule, 'action.apply', isDirty) + .subscribe((res) => { + if (res) { + this.updateData(); + } + }); + } + + private getCalculatedAlarmDialog(value?: AlarmRuleTableEntity, buttonTitle = 'action.add', isDirty = false): Observable { + const entityId = this.entityId || value?.entityId; + const entityName = this.entityName || (value as CalculatedFieldInfo)?.entityName; + return this.dialog.open(AlarmRuleDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + value, + buttonTitle, + entityId, + entityName, + tenantId: this.tenantId, + ownerId: this.ownerId ?? {entityType: EntityType.TENANT, id: this.tenantId}, + additionalDebugActionConfig: this.additionalDebugActionConfig, + isDirty, + getTestScriptDialogFn: this.getTestScriptDialog.bind(this), + }, + enterAnimationDuration: isDirty ? 0 : null, + }) + .afterClosed() + .pipe(filter(Boolean)); + } + + private openDebugEventsDialog($event: Event, calculatedField: AlarmRuleTableEntity): void { + $event?.stopPropagation(); + this.dialog.open(EventsDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + title: 'alarm-rule.debugging', + tenantId: this.tenantId, + entityId: calculatedField.id, + debugEventTypes:[DebugEventType.DEBUG_CALCULATED_FIELD], + disabledEventTypes:[EventType.LC_EVENT, EventType.ERROR, EventType.STATS], + defaultEventType: DebugEventType.DEBUG_CALCULATED_FIELD, + debugActionDisabled: true + } + }) + .afterClosed() + .subscribe(); + } + + private openDebugTab($event: Event, calculatedField: AlarmRuleTableEntity) { + const table = this.getTable(); + if (!table.isDetailsOpen) { + table.toggleEntityDetails($event, calculatedField); + if (table.entityDetailsPanel.matTabGroup._tabs.length > 1) { + table.entityDetailsPanel.matTabGroup.selectedIndex = 1; + } else { + table.entityDetailsPanel.matTabGroup._tabs.changes.pipe( + first() + ).subscribe(() => { + table.entityDetailsPanel.matTabGroup.selectedIndex = 1; + }) + } + } + } + + private exportAlarmRule($event: Event, calculatedField: AlarmRuleTableEntity): void { + $event?.stopPropagation(); + this.importExportService.exportCalculatedField(calculatedField.id.id); + } + + private importCalculatedField(): void { + this.importExportService.openCalculatedFieldImportDialog('alarm-rule.import', 'alarm-rule.file') + .pipe( + filter(Boolean), + switchMap(calculatedField => { + if (calculatedField.type !== CalculatedFieldType.ALARM) { + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant('alarm-rule.import-invalid-alarm-rule-type'), + type: 'error', + verticalPosition: 'top', + horizontalPosition: 'left', + duration: 5000 + })); + return EMPTY; + } + return of(calculatedField); + }), + switchMap(calculatedField => this.getCalculatedAlarmDialog(this.updateImportedCalculatedField(calculatedField), 'action.add', true)), + filter(Boolean), + switchMap(calculatedField => this.calculatedFieldsService.saveCalculatedField(calculatedField)), + filter(Boolean), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.updateData()); + } + + private updateImportedCalculatedField(calculatedField: CalculatedField): CalculatedField { + if (calculatedField.type === CalculatedFieldType.ALARM) { + calculatedField.configuration.arguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { + const arg = calculatedField.configuration.arguments[key]; + acc[key] = arg.refEntityId?.entityType === ArgumentEntityType.Tenant + ? { ...arg, refEntityId: { id: this.tenantId, entityType: ArgumentEntityType.Tenant } } + : arg; + return acc; + }, {}); + } + + return calculatedField; + } + + private onDebugConfigChanged(id: string, debugSettings: EntityDebugSettings): void { + this.calculatedFieldsService.getCalculatedFieldById(id).pipe( + switchMap(field => this.calculatedFieldsService.saveCalculatedField({ ...field, debugSettings })), + catchError(() => of(null)), + takeUntilDestroyed(this.destroyRef), + ).subscribe(() => this.updateData()); + } + + private getTestScriptDialog(calculatedField: AlarmRuleTableEntity, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true, expression?: string): Observable { + if (calculatedField.type === CalculatedFieldType.ALARM) { + const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { + const type = calculatedField.configuration.arguments[key].refEntityKey.type; + acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) + ? {...argumentsObj[key], type} + : type === ArgumentType.Rolling ? {values: [], type} : {value: '', type, ts: new Date().getTime()}; + return acc; + }, {}); + return this.dialog.open(CalculatedFieldScriptTestDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], + data: { + arguments: resultArguments, + expression, + argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments), + argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments), + openCalculatedFieldEdit + } + }).afterClosed() + .pipe( + filter(Boolean), + tap(expression => { + if (openCalculatedFieldEdit) { + this.editCalculatedField(null, { + entityId: this.entityId, ...calculatedField, + configuration: {...calculatedField.configuration, expression} as any + }, true) + } + }), + ); + } else { + return of(null); + } + } + + private openCalculatedField($event: Event, entity: AlarmRuleTableEntity) { + $event?.stopPropagation(); + const url = this.router.createUrlTree(['alarms', 'alarm-rules', entity.id.id]); + this.router.navigateByUrl(url); + } + + private onCFAction(action: EntityAction): boolean { + switch (action.action) { + case 'open': + this.openCalculatedField(action.event, action.entity); + return true; + case 'export': + this.exportAlarmRule(action.event, action.entity); + return true; + } + return false; + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html new file mode 100644 index 0000000000..01a3475345 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html @@ -0,0 +1,20 @@ + +@if (alarmRulesTableConfig) { + +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.scss new file mode 100644 index 0000000000..175df5b779 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.scss @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + .white-background { + --mat-sidenav-content-background-color: white; + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts new file mode 100644 index 0000000000..8ae5af0b57 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts @@ -0,0 +1,99 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + effect, + input, + Renderer2, + ViewChild, +} from '@angular/core'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; +import { DatePipe } from '@angular/common'; +import { AlarmRulesTableConfig } from "@home/components/alarm-rules/alarm-rules-table-config"; +import { UtilsService } from "@core/services/utils.service"; +import { ActivatedRoute, Router } from "@angular/router"; + +@Component({ + selector: 'tb-alarm-rules-table', + templateUrl: './alarm-rules-table.component.html', + styleUrls: ['./alarm-rules-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [EntityDebugSettingsService] +}) +export class AlarmRulesTableComponent { + + @ViewChild(EntitiesTableComponent, {static: true}) entitiesTable: EntitiesTableComponent; + + active = input(); + entityId = input(); + entityName = input(); + ownerId = input(); + + alarmRulesTableConfig: AlarmRulesTableConfig; + + pageMode: boolean = false; + + constructor(private calculatedFieldsService: CalculatedFieldsService, + private translate: TranslateService, + private dialog: MatDialog, + private store: Store, + private datePipe: DatePipe, + private cd: ChangeDetectorRef, + private renderer: Renderer2, + private importExportService: ImportExportService, + private entityDebugSettingsService: EntityDebugSettingsService, + private utilsService: UtilsService, + private destroyRef: DestroyRef, + private route: ActivatedRoute, + private router: Router + ) { + this.pageMode = !!this.route.snapshot.data.isPage; + effect(() => { + if (this.active() || this.pageMode) { + this.alarmRulesTableConfig = new AlarmRulesTableConfig( + this.calculatedFieldsService, + this.translate, + this.dialog, + this.datePipe, + this.entityId(), + this.store, + this.destroyRef, + this.renderer, + this.entityName(), + this.ownerId(), + this.importExportService, + this.entityDebugSettingsService, + this.utilsService, + this.router, + this.pageMode, + ); + this.cd.markForCheck(); + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.html new file mode 100644 index 0000000000..7b1f13756c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.html @@ -0,0 +1,163 @@ + +
+ + + +
+
+
+
+
{{ 'common.general' | translate }}
+
+ + {{ 'entity-field.title' | translate }} + + @if (entityForm.get('name').errors && entityForm.get('name').touched) { + + @if (entityForm.get('name').hasError('required')) { + {{ 'common.hint.title-required' | translate }} + } @else if (entityForm.get('name').hasError('pattern')) { + {{ 'common.hint.title-pattern' | translate }} + } @else if (entityForm.get('name').hasError('maxlength')) { + {{ 'common.hint.title-max-length' | translate }} + } + + } + + + +
+ + +
+ +
+
{{ 'calculated-fields.arguments' | translate }}
+ +
+
+
{{ 'alarm-rule.create-conditions' | translate }}
+
+ + +
+
+
+
{{ 'alarm-rule.clear-condition' | translate }}
+
+
+ + +
+ @if (isEditValue) { + + } +
+
+ alarm-rule.no-clear-alarm-rule +
+
+ +
+
+
+ + + {{ 'alarm-rule.advanced-settings' | translate }} + + +
+
+ + {{ 'alarm-rule.propagate-alarm' | translate }} + +
+ @if (configFormGroup.get('propagate').value) { + + + } +
+
+ + {{ 'alarm-rule.propagate-alarm-to-owner' | translate }} + +
+
+ + {{ 'alarm-rule.propagate-alarm-to-tenant' | translate }} + +
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.ts new file mode 100644 index 0000000000..dbb5693d52 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.ts @@ -0,0 +1,200 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, inject, Inject, Input } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityComponent } from '@home/components/entity/entity.component'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { EntityType } from '@shared/models/entity-type.models'; +import { TranslateService } from '@ngx-translate/core'; +import { + CalculatedFieldArgument, + CalculatedFieldConfiguration, + CalculatedFieldInfo, + CalculatedFieldType +} from '@shared/models/calculated-field.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { BaseData } from '@shared/models/base-data'; +import { Observable } from 'rxjs'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { + CalculatedFieldsTableConfig, + CalculatedFieldsTableEntity +} from '@home/components/calculated-fields/calculated-fields-table-config'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { StringItemsOption } from '@shared/components/string-items-list.component'; +import { RelationTypes } from '@shared/models/relation.models'; +import { + AlarmRule, + AlarmRuleConditionType, + alarmRuleEntityTypeList, + AlarmRuleExpressionType +} from '@shared/models/alarm-rule.models'; +import { CalculatedFieldFormService } from '@core/services/calculated-field-form.service'; +import { AssetInfo } from '@shared/models/asset.models'; +import { DeviceInfo } from '@shared/models/device.models'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { EntityService } from '@core/http/entity.service'; + +@Component({ + selector: 'tb-alarm-rules', + templateUrl: './alarm-rules.component.html', + styleUrls: ['./alarm-rule-dialog.component.scss'] +}) +export class AlarmRulesComponent extends EntityComponent { + + @Input() + standalone = false; + + @Input() + entityName: string; + + ownerId = new TenantId(getCurrentAuthUser(this.store).tenantId); + readonly tenantId = getCurrentAuthUser(this.store).tenantId; + readonly EntityType = EntityType; + readonly alarmRuleEntityTypeList = alarmRuleEntityTypeList; + readonly CalculatedFieldType = CalculatedFieldType; + + private cfFormService = inject(CalculatedFieldFormService); + private destroyRef = inject(DestroyRef); + + constructor(protected store: Store, + protected translate: TranslateService, + @Inject('entity') protected entityValue: CalculatedFieldInfo, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: CalculatedFieldsTableConfig, + protected fb: FormBuilder, + protected cd: ChangeDetectorRef, + private entityService: EntityService) { + super(store, fb, entityValue, entitiesTableConfigValue, cd); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + additionalDebugActionConfig = { + ...this.entitiesTableConfig.additionalDebugActionConfig, + action: () => this.entitiesTableConfig.additionalDebugActionConfig.action( + { id: this.entity.id, ...this.entityFormValue() }, false, + (expression) => { + if (expression) { + this.entityForm.get('configuration').setValue({...this.entityFormValue().configuration, expression}); + this.entityForm.get('configuration').markAsDirty(); + } + }), + }; + + get entityId(): EntityId { + return this.entityForm.get('entityId').value; + } + + get entitiesTableConfig(): CalculatedFieldsTableConfig { + return this.entitiesTableConfigValue; + } + + changeEntity(entity: BaseData): void { + this.entityName = entity?.name; + } + + buildForm(_entity?: CalculatedFieldInfo): FormGroup { + return inject(CalculatedFieldFormService).buildAlarmRuleForm(); + } + + updateForm(entity: CalculatedFieldInfo) { + const { configuration = {} as CalculatedFieldConfiguration, type = CalculatedFieldType.ALARM, debugSettings = { failuresEnabled: true, allEnabled: true }, entityId = this.entityId, ...value } = entity ?? {}; + this.entityForm.patchValue({ configuration, debugSettings, entityId, ...value }, {emitEvent: false}); + if (!entityId) { + this.entityForm.get('configuration').disable({emitEvent: false}); + } + } + + onTestScript(expression?: string): Observable { + return this.cfFormService.testScript( + this.entity?.id?.id, + this.entityFormValue(), + this.entitiesTableConfig.getTestScriptDialog.bind(this.entitiesTableConfig), + this.destroyRef, + expression + ); + } + + updateFormState() { + if (this.entityForm) { + if (this.isEditValue) { + this.entityForm.enable({emitEvent: false}); + this.entityForm.get('entityId').disable({emitEvent: false}); + this.getOwnerId(this.entityId); + } else { + this.entityForm.disable({emitEvent: false}); + } + } + } + + get arguments(): Record { + return this.entityForm.get('configuration.arguments').value; + } + + get predefinedTypeValues(): StringItemsOption[] { + return RelationTypes.map(type => ({ + name: type, + value: type + })); + } + + get configFormGroup(): FormGroup { + return this.entityForm.get('configuration') as FormGroup; + } + + public removeClearAlarmRule() { + this.configFormGroup.patchValue({clearRule: null}); + this.entityForm.markAsDirty(); + } + + public addClearAlarmRule() { + const clearAlarmRule: AlarmRule = { + condition: { + type: AlarmRuleConditionType.SIMPLE, + expression: { + type: AlarmRuleExpressionType.SIMPLE + } + }, + alarmDetails: null + }; + this.configFormGroup.patchValue({clearRule: clearAlarmRule}); + } + + getOwnerId(entityId: EntityId) { + if (entityId?.entityType === EntityType.DEVICE || entityId?.entityType === EntityType.ASSET) { + this.entityService.getEntity(entityId.entityType, entityId.id, { ignoreLoading: true, ignoreErrors: true }).subscribe( + (entity: AssetInfo | DeviceInfo) => { + if (this.isAssignedToCustomer(entity)) { + this.ownerId = entity.customerId; + } + } + ); + } + } + + private isAssignedToCustomer(entity: AssetInfo | DeviceInfo): boolean { + return entity && entity.customerId && entity.customerId.id !== NULL_UUID; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html new file mode 100644 index 0000000000..b9f91a9203 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html @@ -0,0 +1,251 @@ + +
+ +

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

+ + +
+
+
+
+
+
{{ 'alarm-rule.mode' | translate }}
+ + {{ 'alarm-rule.expression-type.simple' | translate }} + {{ 'alarm-rule.expression-type.script' | translate }} + +
+
+
+ @if (conditionFormGroup.get('expression.type').value === AlarmRuleExpressionType.SIMPLE) { +
+
+
{{ 'alarm-rule.argument-filters' | translate }}
+ + {{ complexOperationTranslationMap.get(ComplexOperation.AND) | translate }} + {{ complexOperationTranslationMap.get(ComplexOperation.OR) | translate }} + +
+ + +
+ } @else { +
+
{{ 'alarm-rule.script' | translate }}
+ +
{{ 'alarm-rule.tbel' | translate }} +
+ +
+
+ +
+
+ } +
+ + @if (conditionFormGroup.get('expression.type').value === AlarmRuleExpressionType.SIMPLE) { +
+ + + {{ 'alarm-rule.filter-preview' | translate }} + + +
+ @if (specText) { + {{ specText }} + } + @if (conditionFormGroup.get('expression.filters').value?.length) { + + + } @else { + {{ 'alarm-rule.no-filter-preview' | translate }} + } +
+
+
+
+ } +
+
+ +
+
+
{{ 'alarm-rule.type' | translate }}
+ + + {{ alarmConditionTypeTranslation.get(alarmConditionType) | translate }} + + +
+ @if (isNoData) { +
+ {{ 'alarm-rule.condition-type-hint' | translate }} +
+ } + @if (conditionFormGroup.get('type').value === AlarmConditionType.DURATION) { +
+
+
{{ 'alarm-rule.value' | translate }}
+ + {{ 'alarm-rule.static' | translate }} + {{ 'alarm-rule.dynamic' | translate }} + +
+
+
+ +
+
+ +
+
+ + + + {{ timeUnitTranslations.get(timeUnit) | translate }} + + + + {{ 'alarm-rule.condition-duration-time-unit-required' | translate }} + + +
+
+
+ } @else if (conditionFormGroup.get('type').value === AlarmConditionType.REPEATING) { +
+
+
{{ 'alarm-rule.value' | translate }}
+ + {{ 'alarm-rule.static' | translate }} + {{ 'alarm-rule.dynamic' | translate }} + +
+
+
+ +
+
+ +
+
+
+ } +
+
+
+
+ + @if (!readonly) { + + } +
+ + +
+ + + {{ defaultValuePlaceholder | translate }} + @if (conditionFormGroup.get(groupName).get('staticValue').hasError('required')) { + {{ defaultValueRequiredError | translate }} + } @else if (conditionFormGroup.get(groupName).get('staticValue').hasError('min')) { + {{ defaultValueRangeError | translate }} + } @else if (conditionFormGroup.get(groupName).get('staticValue').hasError('max')) { + {{ defaultValueRangeError | translate }} + } @else if (conditionFormGroup.get(groupName).get('staticValue').hasError('pattern')) { + {{ defaultValuePatternError | translate }} + } + @if (type === AlarmConditionType.REPEATING) { + alarm-rule.condition-repeating-value-hint + } + +
+
+ + + + alarm-rule.value-argument + + @for (argument of argumentsList; track argument) { + {{ argument }} + } + + @if (conditionFormGroup.get(groupName).get('dynamicValueArgument').hasError('required')) { + + {{ 'calculated-fields.hint.argument-name-required' | translate }} + + } + @if (type === AlarmConditionType.REPEATING) { + alarm-rule.condition-repeating-value-hint + } + + + +
+ diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts new file mode 100644 index 0000000000..f39ed364fd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts @@ -0,0 +1,340 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { TimeUnit, timeUnitTranslationMap } from '@shared/models/time/time.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ScriptLanguage } from "@shared/models/rule-node.models"; +import { + CalculatedFieldArgument, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights +} from "@shared/models/calculated-field.models"; +import { TbEditorCompleter } from "@shared/models/ace/completion.models"; +import { AceHighlightRules } from "@shared/models/ace/ace.models"; +import { ComplexOperation } from "@shared/models/query/query.models"; +import { Observable } from "rxjs"; +import { TranslateService } from "@ngx-translate/core"; +import { + AlarmRuleCondition, + AlarmRuleConditionType, + AlarmRuleConditionTypeTranslationMap, + alarmRuleDefaultScript, + AlarmRuleExpressionType, + AlarmRuleFilter, + areFiltersAndPredicateArgumentsValid, + filterOperationTranslationMap +} from "@shared/models/alarm-rule.models"; + +export interface CfAlarmRuleConditionDialogData { + readonly: boolean; + condition: AlarmRuleCondition; + arguments?: Record; + testScript: (expression: string) => Observable; +} + +@Component({ + selector: 'tb-cf-alarm-rule-condition-dialog', + templateUrl: './cf-alarm-rule-condition-dialog.component.html', + providers: [], + styleUrls: ['./cf-alarm-rules-dialog.component.scss'], +}) +export class CfAlarmRuleConditionDialogComponent extends DialogComponent { + + AlarmRuleExpressionType = AlarmRuleExpressionType; + + timeUnits = Object.values(TimeUnit); + timeUnitTranslations = timeUnitTranslationMap; + alarmConditionTypes = Object.values(AlarmRuleConditionType); + AlarmConditionType = AlarmRuleConditionType; + alarmConditionTypeTranslation = AlarmRuleConditionTypeTranslationMap; + readonly = this.data.readonly; + condition = this.data.condition; + + conditionFormGroup = this.fb.group({ + expression: this.fb.group({ + type: [AlarmRuleExpressionType.SIMPLE], + expression: ['', [Validators.required]], + operation: [ComplexOperation.AND], + filters: [null], + }), + type: this.fb.control(AlarmRuleConditionType.SIMPLE, Validators.required), + unit: this.fb.control(TimeUnit.SECONDS, Validators.required), + value: this.fb.group({ + staticValue: this.fb.control(null, [Validators.required, Validators.min(1), Validators.max(2147483647), Validators.pattern('[0-9]*')]), + dynamicValueArgument: this.fb.control('', Validators.required), + }), + count: this.fb.group({ + staticValue: this.fb.control(null, [Validators.required, Validators.min(1), Validators.max(2147483647), Validators.pattern('[0-9]*')]), + dynamicValueArgument: this.fb.control('', Validators.required), + }), + }); + + readonly scriptLanguage = ScriptLanguage; + + defaultValuePlaceholder = ''; + defaultValueRequiredError = ''; + defaultValueRangeError = ''; + defaultValuePatternError = ''; + + durationDynamicModeControl = this.fb.control(false); + repeatingDynamicModeControl = this.fb.control(false); + + ComplexOperation = ComplexOperation; + complexOperationTranslationMap = filterOperationTranslationMap; + + specText = ''; + + filtersValid: boolean = false; + + functionArgs: Array; + argumentsEditorCompleter: TbEditorCompleter; + argumentsHighlightRules: AceHighlightRules; + + arguments = this.data.arguments; + argumentsList: Array; + + isNoData: boolean = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: CfAlarmRuleConditionDialogData, + public dialogRef: MatDialogRef, + private fb: FormBuilder, + private translate: TranslateService) { + super(store, router, dialogRef); + + this.functionArgs = ['ctx', ...Object.keys(this.data.arguments)]; + this.argumentsEditorCompleter = getCalculatedFieldArgumentsEditorCompleter(this.data.arguments); + this.argumentsHighlightRules = getCalculatedFieldArgumentsHighlights(this.data.arguments); + this.argumentsList = this.arguments ? Object.keys(this.arguments): []; + + this.conditionFormGroup.patchValue({ + expression: { + type: this.condition?.expression?.type ?? AlarmRuleExpressionType.SIMPLE, + expression: this.condition?.expression?.expression ?? alarmRuleDefaultScript, + filters: this.condition?.expression?.filters ?? [], + operation: this.condition?.expression?.operation ?? ComplexOperation.AND + }, + type: this.condition?.type ?? AlarmRuleConditionType.SIMPLE, + unit: this.condition?.unit ?? TimeUnit.SECONDS, + value: { + staticValue: this.condition?.value?.staticValue ?? null, + dynamicValueArgument: Object.keys(this.data.arguments).includes(this.condition?.value?.dynamicValueArgument) ? this.condition?.value?.dynamicValueArgument : null, + }, + count: { + staticValue: this.condition?.count?.staticValue ?? null, + dynamicValueArgument: Object.keys(this.data.arguments).includes(this.condition?.count?.dynamicValueArgument) ? this.condition?.count?.dynamicValueArgument : null + } + }, {emitEvent: false}); + + + this.durationDynamicModeControl.patchValue(!!this.condition?.value?.dynamicValueArgument, {emitEvent: false}); + this.repeatingDynamicModeControl.patchValue(!!this.condition?.count?.dynamicValueArgument, {emitEvent: false}); + + this.filtersValid = areFiltersAndPredicateArgumentsValid(this.condition?.expression?.filters, this.data.arguments); + this.checkIsNoData(this.condition?.expression?.filters); + + this.conditionFormGroup.get('type').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((type) => { + this.updateValidators(type); + }); + + this.conditionFormGroup.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((value) => { + this.updateSpecText(value.type); + }) + + this.conditionFormGroup.get('expression.filters').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((filters) => { + this.filtersValid = areFiltersAndPredicateArgumentsValid(filters, this.data.arguments); + this.checkIsNoData(filters); + }); + + this.conditionFormGroup.get('expression.type').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((type) => { + this.updateExpressionTypeValidator(type); + this.updateValidators(this.conditionFormGroup.get('type').value ?? AlarmRuleConditionType.SIMPLE); + }); + + this.durationDynamicModeControl.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((mode) => { + this.updateStaticValueValidator(AlarmRuleConditionType.DURATION, mode); + this.updateSpecText(this.conditionFormGroup.get('type').value); + }); + this.repeatingDynamicModeControl.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((mode) => { + this.updateStaticValueValidator(AlarmRuleConditionType.REPEATING, mode); + this.updateSpecText(this.conditionFormGroup.get('type').value); + }); + + this.updateValidators(this.conditionFormGroup.get('type').value ?? AlarmRuleConditionType.SIMPLE); + this.updateSpecText(this.conditionFormGroup.get('type').value ?? AlarmRuleConditionType.SIMPLE); + this.updateExpressionTypeValidator(this.condition?.expression?.type ?? 'SIMPLE'); + + if (this.readonly) { + this.conditionFormGroup.disable({emitEvent: false}); + } + } + + updateStaticValueValidator(type: AlarmRuleConditionType, dynamicValue: boolean) { + const control = this.conditionFormGroup.get(type === AlarmRuleConditionType.DURATION ? 'value' : 'count'); + if (dynamicValue) { + control.get('staticValue').disable({emitEvent: false}); + control.get('dynamicValueArgument').enable({emitEvent: false}); + } else { + control.get('staticValue').enable({emitEvent: false}); + control.get('dynamicValueArgument').disable({emitEvent: false}); + } + } + + updateExpressionTypeValidator(type: 'SIMPLE' | 'TBEL') { + if (type === 'SIMPLE') { + this.conditionFormGroup.get(`expression.expression`).disable({emitEvent: false}); + this.conditionFormGroup.get(`expression.filters`).enable({emitEvent: false}); + } else { + this.conditionFormGroup.get(`expression.expression`).enable({emitEvent: false}); + this.conditionFormGroup.get(`expression.filters`).disable({emitEvent: false}); + } + } + + private checkIsNoData(filters: Array) { + this.isNoData = this.hasNoData(filters); + if (this.isNoData && this.conditionFormGroup.get('type').value !== AlarmRuleConditionType.SIMPLE) { + this.conditionFormGroup.get('type').patchValue(AlarmRuleConditionType.SIMPLE); + } + } + + private hasNoData(data: Array) { + const search = (filter) => { + if (!filter) return false; + if (Array.isArray(filter)) return filter.some(search); + if (typeof filter !== 'object') return false; + if (filter.type === 'NO_DATA') return true; + if (filter.predicates?.length) return filter.predicates.some(search); + return false; + }; + return search(data); + }; + + private updateValidators(type: AlarmRuleConditionType) { + switch (type) { + case AlarmRuleConditionType.DURATION: + this.conditionFormGroup.get('unit').enable({emitEvent: false}); + this.conditionFormGroup.get('value').enable({emitEvent: false}); + this.conditionFormGroup.get('count').disable({emitEvent: false}); + this.updateStaticValueValidator(type, this.durationDynamicModeControl.value); + this.defaultValuePlaceholder = 'alarm-rule.condition-duration-value'; + this.defaultValueRequiredError = 'alarm-rule.condition-duration-value-required'; + this.defaultValueRangeError = 'alarm-rule.condition-duration-value-range'; + this.defaultValuePatternError = 'alarm-rule.condition-duration-value-pattern'; + break; + case AlarmRuleConditionType.REPEATING: + this.conditionFormGroup.get('count').enable({emitEvent: false}); + this.conditionFormGroup.get('value').disable({emitEvent: false}); + this.conditionFormGroup.get('unit').disable({emitEvent: false}); + this.updateStaticValueValidator(type, this.repeatingDynamicModeControl.value); + this.defaultValuePlaceholder = 'alarm-rule.condition-repeating-value'; + this.defaultValueRequiredError = 'alarm-rule.condition-repeating-value-required'; + this.defaultValueRangeError = 'alarm-rule.condition-repeating-value-range'; + this.defaultValuePatternError = 'alarm-rule.condition-repeating-value-pattern'; + break; + case AlarmRuleConditionType.SIMPLE: + this.conditionFormGroup.get('value').disable({emitEvent: false}); + this.conditionFormGroup.get('count').disable({emitEvent: false}); + this.conditionFormGroup.get('unit').disable({emitEvent: false}); + break; + } + } + + private updateSpecText(type: AlarmRuleConditionType) { + this.specText = ''; + const value = this.conditionFormGroup.get('value').value; + const count = this.conditionFormGroup.get('count').value; + switch (type) { + case AlarmRuleConditionType.SIMPLE: + break; + case AlarmRuleConditionType.DURATION: + let duringText = ''; + switch (this.conditionFormGroup.get('unit').value) { + case TimeUnit.SECONDS: + duringText = this.translate.instant('timewindow.seconds', {seconds: value.staticValue}); + break; + case TimeUnit.MINUTES: + duringText = this.translate.instant('timewindow.minutes', {minutes: value.staticValue}); + break; + case TimeUnit.HOURS: + duringText = this.translate.instant('timewindow.hours', {hours: value.staticValue}); + break; + case TimeUnit.DAYS: + duringText = this.translate.instant('timewindow.days', {days: value.staticValue}); + break; + } + if (this.durationDynamicModeControl.value) { + this.specText = this.translate.instant('alarm-rule.condition-during-dynamic', { + attribute: `${value.dynamicValueArgument ?? ''}` + }) + ' ' + this.translate.instant(this.timeUnitTranslations.get(this.conditionFormGroup.get('unit').value)).toLowerCase(); + } else { + this.specText = this.translate.instant('alarm-rule.condition-during', { + during: duringText.trim() + }); + } + break; + case AlarmRuleConditionType.REPEATING: + if (this.repeatingDynamicModeControl.value) { + this.specText = this.translate.instant('alarm-rule.condition-repeat-times-dynamic', { + attribute: `${count.dynamicValueArgument ?? ''}` + }); + } else { + this.specText = this.translate.instant('alarm-rule.condition-repeat-times', + {count: count.staticValue}); + } + break; + } + if (this.specText.length > 0) { + this.specText = this.specText + ':'; + } + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.dialogRef.close(this.conditionFormGroup.value as AlarmRuleCondition); + } + + onTestScript($event: Event) { + $event?.preventDefault(); + this.data.testScript(this.conditionFormGroup.get('expression.expression').value).subscribe( + (expression) => { + this.conditionFormGroup.get('expression.expression').setValue(expression); + this.conditionFormGroup.get('expression.expression').markAsDirty(); + }) + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html new file mode 100644 index 0000000000..8768a91231 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html @@ -0,0 +1,58 @@ + +
+
+
{{ 'alarm-rule.condition' | translate }}
+ +
+
+
{{ 'alarm-rule.schedule-title' | translate }}
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.scss new file mode 100644 index 0000000000..62876c2476 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.scss @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + display: flex; + flex: 1; + + .tb-alarm-rule-condition { + display: flex; + flex: 1; + &-button { + --mat-outlined-button-horizontal-padding: 3px 0px 12px; + display: block; + width: 100%; + } + &-label { + display: block; + text-align: start; + width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts new file mode 100644 index 0000000000..8ff7e2c083 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts @@ -0,0 +1,344 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ChangeDetectorRef, Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator +} from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { deepClone, isDefinedAndNotNull } from '@core/utils'; +import { TranslateService } from '@ngx-translate/core'; +import { + dayOfWeekTranslations, + getAlarmScheduleRangeText, + utcTimestampToTimeOfDay +} from '@shared/models/device.models'; +import { TimeUnit, timeUnitTranslationMap } from '@shared/models/time/time.models'; +import { + CfAlarmRuleConditionDialogComponent, + CfAlarmRuleConditionDialogData +} from "@home/components/alarm-rules/cf-alarm-rule-condition-dialog.component"; +import { + AlarmRuleCondition, + AlarmRuleConditionType, + AlarmRuleExpressionType, + AlarmRuleSchedule, + AlarmRuleScheduleType, + checkPredicates +} from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { + AlarmRuleScheduleDialogData, + CfAlarmScheduleDialogComponent +} from "@home/components/alarm-rules/cf-alarm-schedule-dialog.component"; +import { coerceBoolean } from "@shared/decorators/coercion"; +import { Observable } from "rxjs"; + +@Component({ + selector: 'tb-cf-alarm-rule-condition', + templateUrl: './cf-alarm-rule-condition.component.html', + styleUrls: ['./cf-alarm-rule-condition.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CfAlarmRuleConditionComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CfAlarmRuleConditionComponent), + multi: true, + } + ] +}) +export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Validator, OnChanges { + + @Input() + @coerceBoolean() + disabled: boolean; + + @Input() + @coerceBoolean() + required: boolean; + + @Input() + arguments: Record; + + @Input() + @coerceBoolean() + isClearCondition = false; + + @Input({required: true}) + testScript: (expression: string) => Observable; + + alarmRuleConditionFormGroup = this.fb.group({ + type: ['SIMPLE'], + expression: [{type: AlarmRuleExpressionType.SIMPLE}], + schedule: [null], + }); + + specText = ''; + + filtersArgumentsValid: boolean = true; + schedulerArgumentsValid: boolean = true; + + scheduleText = ''; + + private modelValue: AlarmRuleCondition; + + private propagateChange = (v: any) => { }; + private onValidatorChange = () => { }; + + constructor(private dialog: MatDialog, + private fb: FormBuilder, + private cd: ChangeDetectorRef, + private translate: TranslateService) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + registerOnValidatorChange(fn: () => void): void { + this.onValidatorChange = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.alarmRuleConditionFormGroup.disable({emitEvent: false}); + } else { + this.alarmRuleConditionFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: AlarmRuleCondition): void { + this.modelValue = value; + this.updateConditionInfo(); + this.recalculateArgumentValidity(); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.arguments) { + if (changes.arguments && !changes.arguments.firstChange) { + this.recalculateArgumentValidity(); + } + } + } + + private recalculateArgumentValidity(): void { + if (!this.modelValue || !this.arguments) { + this.filtersArgumentsValid = true; + this.schedulerArgumentsValid = true; + return; + } + this.filtersArgumentsValid = this.areFilterAndPredicateArgumentsValid(this.modelValue, this.arguments); + this.schedulerArgumentsValid = this.isScheduleArgumentValid(this.modelValue, Object.keys(this.arguments)); + } + + private isScheduleArgumentValid(obj: any, validArguments: string[]): boolean { + const arg = obj?.schedule?.dynamicValueArgument; + return !arg || validArguments.includes(arg); + } + + private areFilterAndPredicateArgumentsValid(obj: any, args: Record): boolean { + const validSet = new Set(Object.keys(args)); + const filters = obj?.expression?.filters || obj?.filters || []; + for (const filter of filters) { + if (filter.argument && !validSet.has(filter.argument)) { + return false; + } + } + for (const filter of filters) { + if (Array.isArray(filter.predicates)) { + if (!checkPredicates(filter.predicates, validSet)) { + return false; + } + } + } + return true; + } + + public conditionSet(): boolean { + return !!this.modelValue && !!(this.modelValue.expression?.expression || this.modelValue.expression?.filters); + } + + public validate(control: AbstractControl): ValidationErrors | null { + const hasCondition = this.conditionSet(); + const argsValid = this.filtersArgumentsValid && this.schedulerArgumentsValid; + return (hasCondition && argsValid) ? null : { alarmRuleCondition: { valid: false } }; + } + + public openFilterDialog($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(CfAlarmRuleConditionDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + readonly: this.disabled, + condition: this.disabled ? this.modelValue : deepClone(this.modelValue), + arguments: this.arguments, + testScript: this.testScript + } + }).afterClosed().subscribe((result) => { + if (result) { + this.modelValue = {...this.modelValue, ...result}; + this.updateModel(); + } + }); + } + + private updateConditionInfo() { + this.alarmRuleConditionFormGroup.patchValue( + this.modelValue ? { + type: this.modelValue?.type, + expression: this.modelValue?.expression, + schedule: this.modelValue?.schedule, + } : null, {emitEvent: false} + ); + this.updateScheduleText(); + this.updateSpecText(); + } + + private updateSpecText() { + this.specText = ''; + if (this.modelValue && this.modelValue.type) { + const type = this.modelValue.type; + switch (type) { + case AlarmRuleConditionType.SIMPLE: + break; + case AlarmRuleConditionType.DURATION: + let duringText = ''; + switch (this.modelValue.unit) { + case TimeUnit.SECONDS: + duringText = this.translate.instant('timewindow.seconds', {seconds: this.modelValue.value.staticValue}); + break; + case TimeUnit.MINUTES: + duringText = this.translate.instant('timewindow.minutes', {minutes: this.modelValue.value.staticValue}); + break; + case TimeUnit.HOURS: + duringText = this.translate.instant('timewindow.hours', {hours: this.modelValue.value.staticValue}); + break; + case TimeUnit.DAYS: + duringText = this.translate.instant('timewindow.days', {days: this.modelValue.value.staticValue}); + break; + } + if (this.modelValue.value.dynamicValueArgument) { + this.specText = this.translate.instant('alarm-rule.condition-during-dynamic', { + attribute: `${this.modelValue.value.dynamicValueArgument}` + }) + ' ' + this.translate.instant(timeUnitTranslationMap.get(this.modelValue.unit)).toLowerCase(); + } else { + this.specText = this.translate.instant('alarm-rule.condition-during', { + during: duringText + }); + } + break; + case AlarmRuleConditionType.REPEATING: + if (this.modelValue.count.dynamicValueArgument) { + this.specText = this.translate.instant('alarm-rule.condition-repeat-times-dynamic', { + attribute: `${this.modelValue.count.dynamicValueArgument}` + }); + } else { + this.specText = this.translate.instant('alarm-rule.condition-repeat-times', + {count: this.modelValue.count.staticValue}); + } + break; + } + } + } + + private updateModel() { + this.updateConditionInfo(); + this.recalculateArgumentValidity(); + this.propagateChange(this.modelValue); + this.onValidatorChange(); + this.cd.detectChanges(); + } + + public openScheduleDialog($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(CfAlarmScheduleDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + readonly: this.disabled, + alarmSchedule: this.disabled ? this.modelValue?.schedule : deepClone(this.modelValue?.schedule), + arguments: this.arguments + } + }).afterClosed().subscribe((result) => { + if (result) { + this.modelValue.schedule = result; + this.updateModel(); + } + }); + } + + private updateScheduleText() { + const schedule = this.modelValue?.schedule; + this.scheduleText = ''; + if (isDefinedAndNotNull(schedule)) { + if (schedule.dynamicValueArgument) { + this.scheduleText = this.translate.instant('alarm-rule.value-argument') + ': ' + schedule?.dynamicValueArgument + } else { + switch (schedule.staticValue.type) { + case AlarmRuleScheduleType.ANY_TIME: + this.scheduleText = this.translate.instant('alarm-rule.schedule.any-time'); + break; + case AlarmRuleScheduleType.SPECIFIC_TIME: + for (const day of schedule.staticValue.daysOfWeek) { + if (this.scheduleText.length) { + this.scheduleText += ', '; + } + this.scheduleText += this.translate.instant(dayOfWeekTranslations[day - 1]); + } + this.scheduleText += ' ' + getAlarmScheduleRangeText(utcTimestampToTimeOfDay(schedule.staticValue.startsOn), + utcTimestampToTimeOfDay(schedule.staticValue.endsOn)) + ''; + break; + case AlarmRuleScheduleType.CUSTOM: + for (const item of schedule.staticValue.items) { + if (item.enabled) { + if (this.scheduleText.length) { + this.scheduleText += ', '; + } + this.scheduleText += this.translate.instant(dayOfWeekTranslations[item.dayOfWeek - 1]); + this.scheduleText += ' ' + getAlarmScheduleRangeText(utcTimestampToTimeOfDay(item.startsOn), + utcTimestampToTimeOfDay(item.endsOn)) + ''; + } + } + break; + } + } + } + if (!this.scheduleText.length) { + this.scheduleText = this.translate.instant('alarm-rule.schedule.any-time'); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html new file mode 100644 index 0000000000..47b6281fdd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html @@ -0,0 +1,46 @@ + +
+ + +
+
+ alarm-rule.alarm-rule-additional-info +
+ + + + +
+
+
+ alarm-rule.alarm-rule-mobile-dashboard +
+ + +
+
diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.scss similarity index 64% rename from ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.scss rename to ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.scss index 3d5a53ea16..53283aad68 100644 --- a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.scss +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,17 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@import '../../../../../scss/constants'; - :host { - display: flex; - flex: 1 1 0; - .tb-request-password-reset-content { - background-color: #eee; - .tb-request-password-reset-card { - @media #{$mat-gt-xs} { - width: 450px !important; - } + width: 100%; + .tb-alarm-rule-details, .tb-alarm-rule-dashboard { + padding: 4px; + &.title { + opacity: 0.7; + overflow: visible; } } + .tb-alarm-rule-details { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + cursor: pointer; + } } + diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts new file mode 100644 index 0000000000..1ba769a19e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts @@ -0,0 +1,157 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { isDefinedAndNotNull } from '@core/utils'; +import { DashboardId } from '@shared/models/id/dashboard-id'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AlarmRule, AlarmRuleCondition } from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { + AlarmRuleDetailsDialogComponent, + AlarmRuleDetailsDialogData +} from "@home/components/alarm-rules/alarm-rule-details-dialog.component"; +import { coerceBoolean } from "@shared/decorators/coercion"; +import { Observable } from "rxjs"; + +@Component({ + selector: 'tb-cf-alarm-rule', + templateUrl: './cf-alarm-rule.component.html', + styleUrls: ['./cf-alarm-rule.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CfAlarmRuleComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CfAlarmRuleComponent), + multi: true, + } + ] +}) +export class CfAlarmRuleComponent implements ControlValueAccessor, OnInit, Validator { + + @Input() + @coerceBoolean() + disabled: boolean; + + @Input() + @coerceBoolean() + required: boolean; + + @Input() + arguments: Record; + + @Input() + @coerceBoolean() + isClearCondition = false; + + @Input({required: true}) + testScript: (expression: string) => Observable; + + private modelValue: AlarmRule; + + alarmRuleFormGroup = this.fb.group({ + condition: this.fb.control(null, Validators.required), + alarmDetails: [null], + dashboardId: [null] + }); + + private propagateChange = (v: any) => { }; + + constructor(private dialog: MatDialog, + private fb: FormBuilder, + private destroyRef: DestroyRef) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.alarmRuleFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.alarmRuleFormGroup.disable({emitEvent: false}); + } else { + this.alarmRuleFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: AlarmRule): void { + this.modelValue = value; + const model = this.modelValue ? { + ...this.modelValue, + dashboardId: this.modelValue.dashboardId?.id + } : null; + this.alarmRuleFormGroup.patchValue(model, {emitEvent: false}); + } + + public openEditDetailsDialog($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AlarmRuleDetailsDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + alarmDetails: this.alarmRuleFormGroup.get('alarmDetails').value, + readonly: this.disabled + } + }).afterClosed().subscribe((alarmDetails) => { + if (isDefinedAndNotNull(alarmDetails)) { + this.alarmRuleFormGroup.patchValue({alarmDetails}); + } + }); + } + + public validate(): ValidationErrors | null { + return (!this.required && !this.modelValue || this.alarmRuleFormGroup.valid) ? null : { + alarmRule: { + valid: false, + }, + }; + } + + private updateModel() { + const value = this.alarmRuleFormGroup.value; + this.modelValue = {...value, dashboardId: value.dashboardId ? new DashboardId(value.dashboardId) : null} as AlarmRule; + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss new file mode 100644 index 0000000000..f38df65c29 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss @@ -0,0 +1,62 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + form { + width: 900px; + max-width: 100%; + display: grid; + grid-template-rows: min-content minmax(auto, 1fr) min-content; + } + + .spec-text { + font-size: 14px; + color: rgba(0, 0, 0, 0.54); + } +} +.tbel-script-lang-chip { + line-height: 20px; + font-size: 14px; + font-weight: 500; + color: white; + border-radius: 100px; + width: 70px; + min-width: 70px; + display: flex; + justify-content: center; + margin-top: 2px; + margin-right: 4px; +} +.tb-js-func { + .ace_tb { + &.ace_calculated-field { + &-ctx { + color: #C52F00; + } + &-args { + color: #185F2A; + } + &-key { + color: #c24c1a; + } + &-time-window, &-values, &-func, &-value, &-ts, &-latestTs { + color: #7214D0; + } + &-start-ts, &-end-ts { + color: #2CAA00; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.html new file mode 100644 index 0000000000..3908d919b3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.html @@ -0,0 +1,60 @@ + +
+ +

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

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

filter.complex-filter

+ +
+
+
+
+
+ {{ 'alarm-rule.filters' | translate }} +
+ + {{ complexOperationTranslations.get(complexOperationEnum.AND) | translate }} + {{ complexOperationTranslations.get(complexOperationEnum.OR) | translate }} + +
+ + +
+
+
+ + @if (!readonly) { + + } +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts new file mode 100644 index 0000000000..017cefb890 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts @@ -0,0 +1,94 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { ComplexOperation, EntityKeyValueType } from '@shared/models/query/query.models'; +import { + AlarmRuleFilterPredicate, + AlarmRuleFilterPredicateType, + ComplexAlarmRuleFilterPredicate, + filterOperationTranslationMap +} from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; + +export interface AlarmRuleComplexFilterPredicateDialogData { + complexPredicate: ComplexAlarmRuleFilterPredicate; + isAdd: boolean; + valueType: EntityKeyValueType; + arguments: Record; + argumentInUse: string; + readonly: boolean; +} + +@Component({ + selector: 'tb-alarm-rule-complex-filter-predicate-dialog', + templateUrl: './alarm-rule-complex-filter-predicate-dialog.component.html', + providers: [], + styleUrls: [] +}) + +export class AlarmRuleComplexFilterPredicateDialogComponent extends + DialogComponent { + + complexFilterFormGroup = this.fb.group( + { + operation: [ComplexOperation.AND, [Validators.required]], + predicates: this.fb.control(null, Validators.required) + } + ); + + EntityKeyValueType = EntityKeyValueType; + complexOperationEnum = ComplexOperation; + complexOperationTranslations = filterOperationTranslationMap; + + isAdd: boolean; + + arguments = this.data.arguments; + + readonly = this.data.readonly; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AlarmRuleComplexFilterPredicateDialogData, + public dialogRef: MatDialogRef, + private fb: FormBuilder) { + super(store, router, dialogRef); + + this.isAdd = this.data.isAdd; + + this.complexFilterFormGroup.patchValue(this.data.complexPredicate, {emitEvent: false}); + + if (this.readonly) { + this.complexFilterFormGroup.disable({emitEvent: false}); + } + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + const predicate = this.complexFilterFormGroup.value as ComplexAlarmRuleFilterPredicate; + predicate.type = AlarmRuleFilterPredicateType.COMPLEX; + this.dialogRef.close(predicate); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html new file mode 100644 index 0000000000..8f6cd68c6a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html @@ -0,0 +1,104 @@ + +
+ +

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

+ +
+
+
+
+
{{ 'alarm-rule.general' | translate }}
+
+ + alarm-rule.value-argument + + @for (argument of argumentsList; track argument) { + {{ argument }} + } + + @if (filterFormGroup.get('argument').touched && filterFormGroup.get('argument').hasError('required')) { + + warning + + } + + + filter.value-type.value-type + + + + {{ entityKeyValueTypes.get(filterFormGroup.get('valueType').value)?.name | translate }} + + + + {{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).name | translate }} + + + + {{ 'filter.value-type-required' | translate }} + + +
+
+ +
+
+
+ {{ 'alarm-rule.filters' | translate }} +
+ + {{ complexOperationTranslationMap.get(ComplexOperation.AND) | translate }} + {{ complexOperationTranslationMap.get(ComplexOperation.OR) | translate }} + +
+ + +
+
+
+
+ + @if (!readonly) { + + } +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.scss new file mode 100644 index 0000000000..4330e0090f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.scss @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + .mat-mdc-form-field.tb-value-type { + mat-select-trigger { + .mat-icon { + vertical-align: middle; + margin-right: 8px; + svg { + vertical-align: initial; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts new file mode 100644 index 0000000000..ba3adc1695 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts @@ -0,0 +1,137 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + + import { Component, DestroyRef, Inject } from '@angular/core'; + import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + import { Store } from '@ngrx/store'; + import { AppState } from '@core/core.state'; + import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + import { Router } from '@angular/router'; + import { DialogComponent } from '@app/shared/components/dialog.component'; + import { ComplexOperation, EntityKeyValueType, entityKeyValueTypesMap } from '@shared/models/query/query.models'; + import { DialogService } from '@core/services/dialog.service'; + import { TranslateService } from '@ngx-translate/core'; + import { + AlarmRuleFilter, + AlarmRuleFilterPredicate, + filterOperationTranslationMap, + isPredicateArgumentsValid + } from "@shared/models/alarm-rule.models"; + import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; + import { FormControlsFrom } from "@shared/models/tenant.model"; + import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + + export interface AlarmRuleFilterDialogData { + filter: AlarmRuleFilter; + isAdd: boolean; + arguments: Record; + usedArguments: Array; + readonly: boolean; +} + +@Component({ + selector: 'tb-alarm-rule-filter-dialog', + templateUrl: './alarm-rule-filter-dialog.component.html', + providers: [], + styleUrls: ['./alarm-rule-filter-dialog.component.scss'] +}) +export class AlarmRuleFilterDialogComponent extends DialogComponent { + + filterFormGroup: FormGroup>; + + entityKeyValueTypesKeys = Object.keys(EntityKeyValueType); + + entityKeyValueTypeEnum = EntityKeyValueType; + + entityKeyValueTypes = entityKeyValueTypesMap; + + complexOperationTranslationMap = filterOperationTranslationMap; + + predicatesValid: boolean = false; + + ComplexOperation = ComplexOperation; + + arguments = this.data.arguments; + argumentsList: Array; + + readonly = this.data.readonly; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AlarmRuleFilterDialogData, + public dialogRef: MatDialogRef, + private dialogs: DialogService, + private translate: TranslateService, + private fb: FormBuilder, + private destroyRef: DestroyRef) { + super(store, router, dialogRef); + + this.argumentsList = this.arguments ? Object.keys(this.arguments) : []; + + this.filterFormGroup = this.fb.group( + { + argument: [this.argumentsList.includes(this.data.filter.argument) ? this.data.filter.argument : '' , [Validators.required]], + valueType: [this.data.filter.valueType ?? EntityKeyValueType.STRING, [Validators.required]], + predicates: [this.data.filter.predicates, [Validators.required]], + operation: [this.data.filter.operation ?? ComplexOperation.AND] + } + ); + + this.predicatesValid = isPredicateArgumentsValid(this.data.filter.predicates, this.arguments); + this.filterFormGroup.get('predicates').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(predicates => { + this.predicatesValid = isPredicateArgumentsValid(predicates, this.arguments); + }); + + this.filterFormGroup.get('valueType').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((valueType: EntityKeyValueType) => { + const prevValueType: EntityKeyValueType = this.filterFormGroup.value.valueType; + const predicates: AlarmRuleFilterPredicate[] = this.filterFormGroup.get('predicates').value; + if (prevValueType && prevValueType !== valueType) { + if (predicates && predicates.length) { + this.dialogs.confirm(this.translate.instant('filter.key-value-type-change-title'), + this.translate.instant('filter.key-value-type-change-message')).subscribe( + (result) => { + if (result) { + this.filterFormGroup.get('predicates').setValue([]); + } else { + this.filterFormGroup.get('valueType').setValue(prevValueType, {emitEvent: false}); + } + } + ); + } + } + }); + + if (this.readonly) { + this.filterFormGroup.disable({emitEvent: false}); + } + } + + argumentInUse(argument: string): boolean { + return this.data.usedArguments.includes(argument); + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.dialogRef.close(this.filterFormGroup.value as AlarmRuleFilter); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html new file mode 100644 index 0000000000..20b0056014 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html @@ -0,0 +1,85 @@ + +@if (filtersFormArray.length) { +
+
+ +
+ + +   +
+
+ +
+ @for (filterControl of filtersFormArray.controls; track filterControl; let index = $index) { +
+
+ @if ($index) { +
+ {{ complexOperationTranslationMap.get(operation) | translate }} +
+ } +
+
+
+
{{ filterControl.value?.argument }}
+
{{ FilterPredicateTypeTranslationMap.get(filterControl.value?.valueType) | translate }}
+ + +
+
+ @if (index) { + + } +
+ } +
+
+} @else { + alarm-rule.no-filter +} + + diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.scss new file mode 100644 index 0000000000..fea71605c8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.scss @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../../../scss/constants"; + +:host { + .filter-title { + padding: 12px 0; + font-size: 14px; + font-weight: 500; + } + .no-data-found { + height: 50px; + font-size: 16px; + } + .filter-list { + overflow: auto; + max-height: 300px; + + &-divider { + border-top: 1px solid rgba(0, 0, 0, 0.12); + } + + .filters-text { + font-size: 14px; + } + } + .filters-operation { + display: flex; + justify-content: center; + margin-top: -14px; + &-container { + background-color: white; + } + &-label { + font-size: 15px; + font-weight: 400; + color: $tb-primary-color; + padding: 0 8px; + border-radius: 4px; + border: 1px solid rgba($tb-primary-color, 0.32); + background-color: rgba($tb-primary-color, 0.04); + } + } + + .tb-budge-button { + --mat-badge-legacy-small-size-container-size: 8px; + --mat-badge-small-size-container-overlap-offset: -5px; + --mat-badge-small-size-text-size: 0; + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.ts new file mode 100644 index 0000000000..142d0d72f2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.ts @@ -0,0 +1,192 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, forwardRef, Input } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormArray, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { Observable } from 'rxjs'; +import { + ComplexOperation, + complexOperationTranslationMap, + EntityKeyValueType +} from '@shared/models/query/query.models'; +import { MatDialog } from '@angular/material/dialog'; +import { deepClone } from '@core/utils'; +import { + AlarmRuleFilterDialogComponent, + AlarmRuleFilterDialogData +} from "@home/components/alarm-rules/filter/alarm-rule-filter-dialog.component"; +import { + AlarmRuleFilter, + areFilterAndPredicateArgumentsValid, + FilterPredicateTypeTranslationMap +} from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +@Component({ + selector: 'tb-alarm-rule-filter-list', + templateUrl: './alarm-rule-filter-list.component.html', + styleUrls: ['./alarm-rule-filter-list.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterListComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterListComponent), + multi: true + } + ] +}) +export class AlarmRuleFilterListComponent implements ControlValueAccessor, Validator { + + @Input() + arguments: Record; + + @Input() + operation: ComplexOperation = ComplexOperation.AND; + + @Input() + readonly = false; + + filterListFormGroup = this.fb.group({ + filters: this.fb.array([]) + }); + + disabled = false; + + areFilterAndPredicateArgumentsValid = areFilterAndPredicateArgumentsValid; + + complexOperationTranslationMap = complexOperationTranslationMap; + FilterPredicateTypeTranslationMap = FilterPredicateTypeTranslationMap + + private propagateChange = (v: any) => { }; + + constructor(private fb: FormBuilder, + private dialog: MatDialog, + private destroyRef: DestroyRef) { + this.filterListFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => this.updateModel()); + } + + + get filtersFormArray(): FormArray { + return this.filterListFormGroup.get('filters') as FormArray; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + validate(): ValidationErrors | null { + return this.filterListFormGroup.valid && this.filterListFormGroup.get('filters').value?.length ? null : { + filterList: {valid: false} + }; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.filterListFormGroup.disable({emitEvent: false}); + } else { + this.filterListFormGroup.enable({emitEvent: false}); + } + } + + writeValue(filters: Array): void { + const keyFilterControls: Array = []; + if (filters) { + for (const filter of filters) { + keyFilterControls.push(this.fb.control(filter, [Validators.required])); + } + } + this.filtersFormArray.clear(); + keyFilterControls.forEach(c => this.filtersFormArray.push(c)); + } + + public removeFilter(index: number) { + (this.filterListFormGroup.get('filters') as FormArray).removeAt(index); + } + + public addFilter() { + const filtersFormArray = this.filterListFormGroup.get('filters') as FormArray; + this.openFilterDialog(null).subscribe(result => { + if (result) { + filtersFormArray.push(this.fb.control(result, [Validators.required])); + } + }); + } + + public editFilter(index: number, readonly = false) { + const filter: AlarmRuleFilter = + (this.filterListFormGroup.get('filters') as FormArray).at(index).value; + this.openFilterDialog(filter, readonly).subscribe(result => { + if (result) { + (this.filterListFormGroup.get('filters') as FormArray).at(index).patchValue(result); + } + }); + } + + private openFilterDialog(filter?: AlarmRuleFilter, readonly = false): Observable { + const isAdd = !filter; + if (isAdd) { + filter = { + argument: null, + valueType: EntityKeyValueType.STRING, + operation: ComplexOperation.AND, + predicates: [] + }; + } + return this.dialog.open(AlarmRuleFilterDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + filter: filter ? deepClone(filter) : null, + isAdd, + arguments: this.arguments, + usedArguments: this.getUsedArguments, + readonly + } + }).afterClosed(); + } + + get getUsedArguments(): Array { + const filters = this.filterListFormGroup.get('filters').value as Array; + return filters.length ? filters.map((filter: AlarmRuleFilter) => filter.argument) : []; + } + + private updateModel() { + const filters = this.filterListFormGroup.value.filters as Array; + this.propagateChange(filters); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html new file mode 100644 index 0000000000..1dd6f5f453 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html @@ -0,0 +1,80 @@ + +
+ @if (predicatesFormArray.length) { +
+
+
+
+ + + +
+   +
+
+ +
+ @for (predicateControl of predicatesFormArray.controls; track predicateControl; let index = $index) { +
+ @if (index) { +
+ {{ complexOperationTranslations.get(operation) | translate }} +
+ } +
+
+ + + +
+
+
+ } +
+
+ } @else { + alarm-rule.no-filter + } +
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.scss new file mode 100644 index 0000000000..e5e6dbeba4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.scss @@ -0,0 +1,52 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../../../scss/constants"; + +:host { + .filter-title { + padding: 12px 8px; + font-size: 14px; + font-weight: 500; + } + .no-data-found { + height: 50px; + font-size: 16px; + } + + .key-filter-list-divider { + border-top: 1px solid rgba(0, 0, 0, 0.12); + } + .filters-operation { + display: flex; + justify-content: center; + margin-top: -14px; + &-container { + position: absolute; + top: -12px; + left: 10px; + background-color: white; + } + &-label { + font-size: 15px; + font-weight: 400; + color: $tb-primary-color; + padding: 0 8px; + border-radius: 4px; + border: 1px solid rgba($tb-primary-color, 0.32); + background-color: rgba($tb-primary-color, 0.04); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.ts new file mode 100644 index 0000000000..7bf08c9ac9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.ts @@ -0,0 +1,213 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, forwardRef, Input } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormArray, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { Observable, of } from 'rxjs'; +import { + ComplexOperation, + complexOperationTranslationMap, + EntityKeyValueType, + entityKeyValueTypeToFilterPredicateType +} from '@shared/models/query/query.models'; +import { MatDialog } from '@angular/material/dialog'; +import { map } from 'rxjs/operators'; +import { + AlarmRuleComplexFilterPredicateDialogComponent, + AlarmRuleComplexFilterPredicateDialogData +} from "@home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component"; +import { + AlarmRuleBooleanOperation, + AlarmRuleFilterPredicate, + AlarmRuleFilterPredicateType, + AlarmRuleNumericOperation, + AlarmRulePredicateInfo, + AlarmRuleStringOperation, + ComplexAlarmRuleFilterPredicate +} from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +@Component({ + selector: 'tb-alarm-rule-filter-predicate-list', + templateUrl: './alarm-rule-filter-predicate-list.component.html', + styleUrls: ['./alarm-rule-filter-predicate-list.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterPredicateListComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterPredicateListComponent), + multi: true + } + ] +}) +export class AlarmRuleFilterPredicateListComponent implements ControlValueAccessor, Validator { + + @Input() disabled: boolean; + + @Input() valueType: EntityKeyValueType; + + @Input() operation: ComplexOperation = ComplexOperation.AND; + + @Input() arguments: Record; + + @Input() argumentInUse: string; + + @Input() readonly: boolean; + + filterListFormGroup = this.fb.group({ + predicates: this.fb.array([]) + }); + + valueTypeEnum = EntityKeyValueType; + + complexOperationTranslations = complexOperationTranslationMap; + + private propagateChange= (v: any) => { }; + + constructor(private fb: FormBuilder, + private dialog: MatDialog, + private destroyRef: DestroyRef) { + this.filterListFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => this.updateModel()); + } + + get predicatesFormArray(): FormArray { + return this.filterListFormGroup.get('predicates') as FormArray; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.filterListFormGroup.disable({emitEvent: false}); + } else { + this.filterListFormGroup.enable({emitEvent: false}); + } + } + + validate(control: AbstractControl): ValidationErrors | null { + return this.filterListFormGroup.valid ? null : { + filterList: {valid: false} + }; + } + + writeValue(predicates: Array): void { + const predicateControls: Array = []; + if (predicates) { + for (const predicate of predicates) { + predicateControls.push(this.fb.control(predicate, [Validators.required])); + } + } + this.predicatesFormArray.clear(); + predicateControls.forEach(predicate => this.predicatesFormArray.push(predicate)); + } + + public removePredicate(index: number) { + this.predicatesFormArray.removeAt(index); + } + + public addPredicate(complex: boolean) { + const predicatesFormArray = this.filterListFormGroup.get('predicates') as FormArray; + const predicate = this.createDefaultFilterPredicate(this.valueType, complex); + let observable: Observable; + if (complex) { + observable = this.openComplexFilterDialog(predicate as ComplexAlarmRuleFilterPredicate); + } else { + observable = of(predicate); + } + observable.subscribe((result) => { + if (result) { + predicatesFormArray.push(this.fb.control(result, [Validators.required])); + } + }); + } + + private createDefaultFilterPredicate(valueType: EntityKeyValueType, complex: boolean): AlarmRuleFilterPredicate { + const predicate = { + type: complex ? AlarmRuleFilterPredicateType.COMPLEX : entityKeyValueTypeToFilterPredicateType(valueType) + } as AlarmRuleFilterPredicate; + switch (predicate.type) { + case AlarmRuleFilterPredicateType.STRING: + predicate.operation = AlarmRuleStringOperation.STARTS_WITH; + predicate.value = { + staticValue: '' + }; + predicate.ignoreCase = false; + break; + case AlarmRuleFilterPredicateType.NUMERIC: + predicate.operation = AlarmRuleNumericOperation.EQUAL; + predicate.value = { + staticValue: valueType === EntityKeyValueType.DATE_TIME ? Date.now() : 0 + }; + break; + case AlarmRuleFilterPredicateType.BOOLEAN: + predicate.operation = AlarmRuleBooleanOperation.EQUAL; + predicate.value = { + staticValue: false + }; + break; + case AlarmRuleFilterPredicateType.COMPLEX: + predicate.operation = ComplexOperation.AND; + predicate.predicates = []; + break; + } + return predicate; + } + + private openComplexFilterDialog(predicate: ComplexAlarmRuleFilterPredicate): Observable { + return this.dialog.open(AlarmRuleComplexFilterPredicateDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + complexPredicate: predicate as ComplexAlarmRuleFilterPredicate, + valueType: this.valueType, + isAdd: true, + arguments: this.arguments, + argumentInUse: this.argumentInUse, + readonly: this.readonly + } + }).afterClosed().pipe( + map(result => result) + ); + } + + private updateModel() { + this.propagateChange(this.filterListFormGroup.get('predicates').value); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.html new file mode 100644 index 0000000000..09b468aeb9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.html @@ -0,0 +1,77 @@ + +
+
+ + + {{'alarm-rule.static' | translate}} + {{'alarm-rule.dynamic' | translate}} + + + + @if (!dynamicModeControl.value) { + + + @if (filterPredicateValueNoDataFormGroup.get('duration.staticValue').touched && filterPredicateValueNoDataFormGroup.get('duration.staticValue').errors) { + + warning + + } + + } @else { + + + @for (argument of argumentsList; track argument) { + {{ argument }} + } + + @if (filterPredicateValueNoDataFormGroup.get('duration.dynamicValueArgument').touched && filterPredicateValueNoDataFormGroup.get('duration.dynamicValueArgument').errors) { + + warning + + } + @if (filterPredicateValueNoDataFormGroup.get('duration.dynamicValueArgument').hasError('argumentInUse')) { + + warning + + } + + } + + + + + {{ timeUnitsTranslationMap.get(timeUnit) | translate }} + + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.ts new file mode 100644 index 0000000000..7f9608bd5c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.ts @@ -0,0 +1,166 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { TimeUnit, timeUnitTranslations } from "@home/components/rule-node/rule-node-config.models"; +import { AlarmRuleFilterPredicateType, NoDataAlarmRuleFilterPredicate } from "@shared/models/alarm-rule.models"; +import { isDefinedAndNotNull } from "@core/utils"; + +@Component({ + selector: 'tb-alarm-rule-filter-predicate-no-data-value', + templateUrl: './alarm-rule-filter-predicate-no-data-value.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterPredicateNoDataValueComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterPredicateNoDataValueComponent), + multi: true + } + ] +}) +export class AlarmRuleFilterPredicateNoDataValueComponent implements ControlValueAccessor, Validator, OnInit, OnChanges { + + @Input() + arguments: Record; + + @Input() + valueType: AlarmRuleFilterPredicateType; + + @Input() + argumentInUse: string; + + valueTypeEnum = AlarmRuleFilterPredicateType; + + filterPredicateValueNoDataFormGroup = this.fb.group({ + type: ['NO_DATA'], + unit: [TimeUnit.MINUTES, Validators.required], + duration: this.fb.group({ + staticValue: [null as null | number, [Validators.required, Validators.min(1)]], + dynamicValueArgument: ['', Validators.required] + }) + }); + + timeUnits = [TimeUnit.MINUTES, TimeUnit.HOURS, TimeUnit.DAYS]; + timeUnitsTranslationMap = timeUnitTranslations; + + + dynamicModeControl = this.fb.control(false); + + argumentsList: Array; + + private propagateChange= (v: any) => { }; + + constructor(private fb: FormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + this.argumentsList = this.arguments ? Object.keys(this.arguments): []; + this.filterPredicateValueNoDataFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + this.dynamicModeControl.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(value => this.updateValueModeValidators(value)); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.argumentInUse) { + const argumentInUseChanges = changes.argumentInUse; + if (!argumentInUseChanges.firstChange && argumentInUseChanges.currentValue !== argumentInUseChanges.previousValue) { + if (this.dynamicModeControl.value) { + if (this.argumentInUse === this.filterPredicateValueNoDataFormGroup.get('duration.dynamicValueArgument').value) { + this.filterPredicateValueNoDataFormGroup.get('duration.dynamicValueArgument').setErrors({argumentInUse: true}); + this.filterPredicateValueNoDataFormGroup.updateValueAndValidity(); + } + } + } + } + } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.filterPredicateValueNoDataFormGroup.disable({emitEvent: false}); + this.dynamicModeControl.disable({emitEvent: false}); + } else { + this.filterPredicateValueNoDataFormGroup.enable({emitEvent: false}); + this.dynamicModeControl.enable({emitEvent: false}); + this.updateValueModeValidators(this.dynamicModeControl.value); + } + } + + private updateValueModeValidators(isDynamicMode: boolean): void { + if (isDynamicMode) { + this.filterPredicateValueNoDataFormGroup.get('duration.staticValue').disable({emitEvent: false}); + this.filterPredicateValueNoDataFormGroup.get('duration.dynamicValueArgument').enable(); + setTimeout(()=> { + if (this.filterPredicateValueNoDataFormGroup.get('duration.dynamicValueArgument').value && this.argumentInUse === this.filterPredicateValueNoDataFormGroup.get('dynamicValueArgument').value) { + this.filterPredicateValueNoDataFormGroup.get('duration.dynamicValueArgument').setErrors({argumentInUse: true}); + this.filterPredicateValueNoDataFormGroup.updateValueAndValidity(); + } + }, 0); + } else { + this.filterPredicateValueNoDataFormGroup.get('duration.dynamicValueArgument').disable({emitEvent: false}); + this.filterPredicateValueNoDataFormGroup.get('duration.staticValue').enable(); + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + validate(): ValidationErrors | null { + return this.filterPredicateValueNoDataFormGroup.valid ? null : { + filterPredicateValue: {valid: false} + }; + } + + writeValue(predicateValue: NoDataAlarmRuleFilterPredicate): void { + if (isDefinedAndNotNull(predicateValue?.duration?.dynamicValueArgument)) { + const availableArgument = this.argumentsList.filter(arg => arg !== this.argumentInUse); + if (!availableArgument.includes(predicateValue.duration.dynamicValueArgument)) { + predicateValue.duration.dynamicValueArgument = ''; + } + this.dynamicModeControl.patchValue(true, {emitEvent: false}); + } + this.filterPredicateValueNoDataFormGroup.patchValue(predicateValue, {emitEvent: false}); + } + + private updateModel() { + this.propagateChange(this.filterPredicateValueNoDataFormGroup.value); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.html new file mode 100644 index 0000000000..9db8a856bf --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.html @@ -0,0 +1,82 @@ + +
+
+ + + {{'alarm-rule.static' | translate}} + {{'alarm-rule.dynamic' | translate}} + + + @if (!dynamicModeControl.value) { + @switch (valueType) { + @case (valueTypeEnum.STRING) { + + + + } + @case (valueTypeEnum.NUMERIC) { + + + + } + @case (valueTypeEnum.BOOLEAN) { + + {{ (filterPredicateValueFormGroup.get('staticValue').value ? 'value.true' : 'value.false') | translate }} + + } + @case (valueTypeEnum.DATE_TIME) { + + } + } + } @else { + + + @for (argument of argumentsList; track argument) { + {{ argument }} + } + + @if (filterPredicateValueFormGroup.get('dynamicValueArgument').touched && filterPredicateValueFormGroup.get('dynamicValueArgument').hasError('required')) { + + warning + + } + @if (filterPredicateValueFormGroup.get('dynamicValueArgument').hasError('argumentInUse')) { + + warning + + } + + } +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts new file mode 100644 index 0000000000..bd6bacb762 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts @@ -0,0 +1,182 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + ValidatorFn, + Validators +} from '@angular/forms'; +import { EntityKeyValueType } from '@shared/models/query/query.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { AlarmRuleValue } from "@shared/models/alarm-rule.models"; +import { FormControlsFrom } from "@shared/models/tenant.model"; +import { isDefinedAndNotNull } from "@core/utils"; + +@Component({ + selector: 'tb-alarm-rule-filter-predicate-value', + templateUrl: './alarm-rule-filter-predicate-value.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterPredicateValueComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterPredicateValueComponent), + multi: true + } + ] +}) +export class AlarmRuleFilterPredicateValueComponent implements ControlValueAccessor, Validator, OnInit, OnChanges { + + @Input() + arguments: Record; + + @Input() + valueType: EntityKeyValueType; + + @Input() + argumentInUse: string; + + valueTypeEnum = EntityKeyValueType; + + filterPredicateValueFormGroup: FormGroup>>; + + dynamicModeControl = this.fb.control(false); + + argumentsList: Array; + + private propagateChange= (v: any) => { }; + + constructor(private fb: FormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + this.argumentsList = this.arguments ? Object.keys(this.arguments): []; + let defaultValue: string | number | boolean; + let defaultValueValidators: ValidatorFn[]; + switch (this.valueType) { + case EntityKeyValueType.STRING: + defaultValue = ''; + defaultValueValidators = []; + break; + case EntityKeyValueType.NUMERIC: + defaultValue = 0; + defaultValueValidators = [Validators.required]; + break; + case EntityKeyValueType.BOOLEAN: + defaultValue = false; + defaultValueValidators = []; + break; + case EntityKeyValueType.DATE_TIME: + defaultValue = Date.now(); + defaultValueValidators = [Validators.required]; + break; + } + this.filterPredicateValueFormGroup = this.fb.group({ + staticValue: [defaultValue, defaultValueValidators], + dynamicValueArgument: ['', Validators.required] + }); + this.filterPredicateValueFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + this.dynamicModeControl.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(value => this.updateValueModeValidators(value)); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.argumentInUse) { + const argumentInUseChanges = changes.argumentInUse; + if (!argumentInUseChanges.firstChange && argumentInUseChanges.currentValue !== argumentInUseChanges.previousValue) { + if (this.dynamicModeControl.value) { + if (this.argumentInUse === this.filterPredicateValueFormGroup.get('dynamicValueArgument').value) { + this.filterPredicateValueFormGroup.get('dynamicValueArgument').setErrors({argumentInUse: true}); + this.filterPredicateValueFormGroup.updateValueAndValidity(); + } + } + } + } + } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.filterPredicateValueFormGroup.disable({emitEvent: false}); + this.dynamicModeControl.disable({emitEvent: false}); + } else { + this.filterPredicateValueFormGroup.enable({emitEvent: false}); + this.dynamicModeControl.enable({emitEvent: false}); + this.updateValueModeValidators(this.dynamicModeControl.value); + } + } + + private updateValueModeValidators(isDynamicMode: boolean): void { + if (isDynamicMode) { + this.filterPredicateValueFormGroup.get('staticValue').disable({emitEvent: false}); + this.filterPredicateValueFormGroup.get('dynamicValueArgument').enable(); + setTimeout(()=> { + if (this.filterPredicateValueFormGroup.get('dynamicValueArgument').value && this.argumentInUse === this.filterPredicateValueFormGroup.get('dynamicValueArgument').value) { + this.filterPredicateValueFormGroup.get('dynamicValueArgument').setErrors({argumentInUse: true}); + this.filterPredicateValueFormGroup.updateValueAndValidity(); + } + }, 0); + } else { + this.filterPredicateValueFormGroup.get('dynamicValueArgument').disable({emitEvent: false}); + this.filterPredicateValueFormGroup.get('staticValue').enable(); + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + validate(): ValidationErrors | null { + return this.filterPredicateValueFormGroup.valid ? null : { + filterPredicateValue: {valid: false} + }; + } + + writeValue(predicateValue: AlarmRuleValue): void { + if (isDefinedAndNotNull(predicateValue?.dynamicValueArgument)) { + const availableArgument = this.argumentsList.filter(arg => arg !== this.argumentInUse); + if (!availableArgument.includes(predicateValue.dynamicValueArgument)) { + predicateValue.dynamicValueArgument = ''; + } + this.dynamicModeControl.patchValue(true, {emitEvent: false}); + } + this.filterPredicateValueFormGroup.patchValue(predicateValue, {emitEvent: false}); + } + + private updateModel() { + this.propagateChange(this.filterPredicateValueFormGroup.value); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html new file mode 100644 index 0000000000..9d3ad7286a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html @@ -0,0 +1,86 @@ + +
+
+ @switch (type) { + @case (filterPredicateType.STRING) { +
+ + + + {{stringOperationTranslationMap.get(stringOperation[operation]) | translate}} + + + + @if (filterPredicateFormGroup.get('operation').value !== stringOperation.NO_DATA) { + + {{ 'alarm-rule.ignore-case' | translate }} + + } +
+ } + @case (filterPredicateType.NUMERIC) { +
+ + + + {{numericOperationTranslations.get(numericOperationEnum[operation]) | translate}} + + + +
+ } + @case (filterPredicateType.BOOLEAN) { +
+ + + + {{booleanOperationTranslations.get(booleanOperationEnum[operation]) | translate}} + + + +
+ } + @case (filterPredicateType.COMPLEX) { +
+ +
+ } + } + + + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.ts new file mode 100644 index 0000000000..77cc962747 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.ts @@ -0,0 +1,216 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { booleanAttribute, Component, DestroyRef, forwardRef, Input } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator +} from '@angular/forms'; +import { EntityKeyValueType } from '@shared/models/query/query.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + AlarmRuleBooleanOperation, + alarmRuleBooleanOperationTranslationMap, + AlarmRuleFilterPredicate, + AlarmRuleFilterPredicateType, + AlarmRuleNumericOperation, + alarmRuleNumericOperationTranslationMap, + AlarmRuleStringOperation, + alarmRuleStringOperationTranslationMap, + checkPredicates, + ComplexAlarmRuleFilterPredicate +} from "@shared/models/alarm-rule.models"; +import { MatDialog } from "@angular/material/dialog"; +import { + AlarmRuleComplexFilterPredicateDialogComponent, + AlarmRuleComplexFilterPredicateDialogData +} from "@home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; + +@Component({ + selector: 'tb-alarm-rule-filter-predicate', + templateUrl: './alarm-rule-filter-predicate.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterPredicateComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterPredicateComponent), + multi: true + } + ] +}) +export class AlarmRuleFilterPredicateComponent implements ControlValueAccessor, Validator { + + @Input({ transform: booleanAttribute }) + disabled: boolean; + + @Input() + valueType: EntityKeyValueType; + + @Input() + arguments: Record; + + @Input() + argumentInUse: string; + + filterPredicateFormGroup = this.fb.group({ + operation: [], + ignoreCase: false, + predicates: [], + value: [], + duration: [] + }); + + type: AlarmRuleFilterPredicateType; + + filterPredicateType = AlarmRuleFilterPredicateType; + + stringOperations = Object.keys(AlarmRuleStringOperation); + stringOperation = AlarmRuleStringOperation; + stringOperationTranslationMap = alarmRuleStringOperationTranslationMap; + + numericOperations = Object.keys(AlarmRuleNumericOperation); + numericOperationEnum = AlarmRuleNumericOperation; + numericOperationTranslations = alarmRuleNumericOperationTranslationMap; + + booleanOperations = Object.keys(AlarmRuleBooleanOperation); + booleanOperationEnum = AlarmRuleBooleanOperation; + booleanOperationTranslations = alarmRuleBooleanOperationTranslationMap; + + predicateValid: boolean = false; + + private propagateChange= (v: any) => { }; + + constructor(private fb: FormBuilder, + private dialog: MatDialog, + private destroyRef: DestroyRef) { + this.filterPredicateFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + + this.filterPredicateFormGroup.get('operation').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(value => { + if (value === 'NO_DATA') { + this.filterPredicateFormGroup.get('duration').enable({emitEvent: false}); + this.filterPredicateFormGroup.get('value').disable({emitEvent: false}); + } else { + this.filterPredicateFormGroup.get('duration').disable({emitEvent: false}); + this.filterPredicateFormGroup.get('value').enable({emitEvent: false}); + } + }) + + this.filterPredicateFormGroup.get('predicates').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(predicates => { + this.predicateValid = this.isPredicateArgumentsValid(predicates); + }) + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + validate(): ValidationErrors | null { + return this.filterPredicateFormGroup.valid ? null : { + filterPredicate: {valid: false} + }; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.filterPredicateFormGroup.disable({emitEvent: false}); + } else { + this.filterPredicateFormGroup.enable({emitEvent: false}); + if (this.filterPredicateFormGroup.get('operation').value === 'NO_DATA') { + this.filterPredicateFormGroup.get('duration').enable({emitEvent: false}); + this.filterPredicateFormGroup.get('value').disable({emitEvent: false}); + } else { + this.filterPredicateFormGroup.get('duration').disable({emitEvent: false}); + this.filterPredicateFormGroup.get('value').enable({emitEvent: false}); + } + } + } + + private isPredicateArgumentsValid(predicates: AlarmRuleFilterPredicate[]): boolean { + const validSet = new Set(Object.keys(this.arguments)); + if (Array.isArray(predicates)) { + if (!checkPredicates(predicates, validSet)) { + return false; + } + } + return true; + } + + writeValue(predicate: AlarmRuleFilterPredicate): void { + this.type = predicate.type; + if ((predicate as ComplexAlarmRuleFilterPredicate)?.predicates) { + this.predicateValid = this.isPredicateArgumentsValid((predicate as ComplexAlarmRuleFilterPredicate)?.predicates); + } + if (predicate.type === AlarmRuleFilterPredicateType.NO_DATA) { + this.type = AlarmRuleFilterPredicateType[this.valueType]; + this.filterPredicateFormGroup.patchValue({operation: 'NO_DATA', duration: predicate}, {emitEvent: false}); + } else { + this.filterPredicateFormGroup.patchValue(predicate, {emitEvent: false}); + } + } + + private updateModel() { + const predicate = this.filterPredicateFormGroup.value; + if (predicate.operation === 'NO_DATA') { + this.propagateChange(predicate.duration); + } else { + this.propagateChange({type: this.type, ...predicate}); + } + } + + public openComplexFilterDialog() { + this.dialog.open(AlarmRuleComplexFilterPredicateDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + complexPredicate: this.filterPredicateFormGroup.value as ComplexAlarmRuleFilterPredicate, + valueType: this.valueType, + isAdd: false, + arguments: this.arguments, + argumentInUse: this.argumentInUse, + readonly: this.disabled + } + }).afterClosed().subscribe( + (result) => { + if (result) { + this.filterPredicateFormGroup.patchValue(result); + } + } + ); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.html new file mode 100644 index 0000000000..9e20a079b4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.html @@ -0,0 +1,25 @@ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.scss new file mode 100644 index 0000000000..5f621d7bb1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.scss @@ -0,0 +1,91 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + text-overflow: ellipsis; + overflow: hidden; + .tb-filter-text { + overflow-y: auto; + text-align: start; + &.required { + color: #f44336; + padding: 0; + } + &.disabled { + color: rgba(0,0,0,0.38); + } + &.nowrap { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + &.wrap { + white-space: pre-wrap !important; + } + } +} + +:host ::ng-deep { + .tb-filter-text { + line-height: 1.8em; + span { + display: inline-block; + vertical-align: middle; + line-height: 1.4em; + } + .tb-filter-predicate { + padding-right: 4px; + padding-left: 4px; + } + .tb-filter-entity-key, .tb-filter-value, .tb-filter-dynamic-source { + font-weight: bold; + border: 1px groove rgba(0, 0, 0, .25); + border-radius: 4px; + padding-left: 4px; + padding-right: 4px; + } + .tb-filter-entity-key, .tb-filter-value { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 150px; + } + .tb-filter-dynamic-source { + } + .tb-filter-entity-key { + color: #305680; + } + .tb-filter-value { + color: #ff5722; + } + .tb-filter-simple-operation { + font-size: 0.9em; + } + .tb-filter-complex-operation { + font-weight: 400; + font-style: italic; + } + .tb-filter-dynamic-value { + .tb-filter-dynamic-source, .tb-filter-value { + color: #0c959c; + } + } + .tb-filter-bracket { + .tb-left-bracket, .tb-right-bracket { + font-size: 1.2em; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.ts new file mode 100644 index 0000000000..7bb8bada5d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.ts @@ -0,0 +1,204 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Input } from '@angular/core'; +import { + ComplexOperation, + complexOperationTranslationMap, + EntityKeyValueType +} from '@shared/models/query/query.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { + alarmRuleBooleanOperationTranslationMap, + AlarmRuleExpression, + AlarmRuleExpressionType, + AlarmRuleFilter, + AlarmRuleFilterPredicate, + AlarmRuleFilterPredicateType, + alarmRuleNumericOperationTranslationMap, + AlarmRuleStringOperation, + alarmRuleStringOperationTranslationMap, + ComplexAlarmRuleFilterPredicate +} from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { coerceBoolean } from "@shared/decorators/coercion"; +import { timeUnitTranslationMap } from "@shared/models/time/time.models"; + +@Component({ + selector: 'tb-alarm-rule-filter-text', + templateUrl: './alarm-rule-filter-text.component.html', + styleUrls: ['./alarm-rule-filter-text.component.scss'], + providers: [] +}) +export class AlarmRuleFilterTextComponent { + + @Input() + @coerceBoolean() + required = false; + + @Input() + noFilterText = this.translate.instant('filter.no-filter-text'); + + @Input() + addFilterPrompt = this.translate.instant('filter.add-filter-prompt'); + + @Input() + @coerceBoolean() + nowrap = false; + + @Input() + arguments: Record; + + @Input() + @coerceBoolean() + disabled = false; + + private alarmRuleExpressionValue: AlarmRuleExpression; + get alarmRuleExpression(): AlarmRuleExpression { + return this.alarmRuleExpressionValue; + } + + @Input() + set alarmRuleExpression(value: AlarmRuleExpression) { + if (value !== this.alarmRuleExpressionValue) { + this.alarmRuleExpressionValue = value; + this.updateFilterText(value); + } + }; + + private specTextValue: string; + get specText(): string { + return this.specTextValue; + } + @Input() + set specText(value: string) { + if (value !== this.specTextValue) { + this.specTextValue = value; + this.updateFilterText(this.alarmRuleExpression); + } + } + + isRequired = false; + + public filterText: string; + + constructor(private translate: TranslateService, + private datePipe: DatePipe) { + } + + private updateFilterText(value: AlarmRuleExpression) { + this.isRequired = false; + if (value && (value.expression || value.filters?.length)) { + if (value.type === AlarmRuleExpressionType.SIMPLE) { + this.filterText = this.keyFiltersToText(this.translate, this.datePipe, value.filters, value.operation); + } else { + this.filterText = 'function expression(ctx, ' + (this.arguments ? Object.keys(this.arguments).join(', ') : '' ) + ')'; + } + if (this.specText?.length) { + this.filterText = this.specText + ': ' + this.filterText; + } + } else { + if (this.required) { + this.filterText = this.addFilterPrompt; + this.isRequired = true; + } else { + this.filterText = this.noFilterText; + } + } + } + + private keyFiltersToText(translate: TranslateService, datePipe: DatePipe, keyFilters: Array, operation: ComplexOperation): string { + const filtersText = keyFilters.map(keyFilter => + this.filterPredicateToText(translate, datePipe, keyFilter, keyFilter.predicates)); + let result: string; + if (filtersText.length > 1) { + const operationText = translate.instant(complexOperationTranslationMap.get(operation)); + result = filtersText.join(' ' + operationText + ' '); + } else { + result = filtersText[0]; + } + return result; + } + + private filterPredicateToText(translate: TranslateService, + datePipe: DatePipe, + keyFilter: AlarmRuleFilter, + keyFilterPredicates: AlarmRuleFilterPredicate[], + complexOperation?: ComplexOperation): string { + const key = keyFilter.argument; + const filterOperation: ComplexOperation = complexOperation ? complexOperation : (keyFilter.operation ?? ComplexOperation.AND); + + const predicates = keyFilterPredicates.map((keyFilterPredicate: AlarmRuleFilterPredicate) => { + if (keyFilterPredicate.type === AlarmRuleFilterPredicateType.COMPLEX) { + const complexPredicate = keyFilterPredicate as ComplexAlarmRuleFilterPredicate; + const complexOperation = complexPredicate.operation ?? ComplexOperation.AND; + return this.filterPredicateToText(translate, datePipe, keyFilter, complexPredicate.predicates, complexOperation); + } else { + let operation: string; + let value: string; + const val = keyFilterPredicate.type === AlarmRuleFilterPredicateType.NO_DATA ? keyFilterPredicate.duration : keyFilterPredicate.value; + const dynamicValue = val?.dynamicValueArgument?.length; + if (dynamicValue) { + value = '' + val?.dynamicValueArgument + ''; + } + switch (keyFilterPredicate.type) { + case AlarmRuleFilterPredicateType.STRING: + operation = translate.instant(alarmRuleStringOperationTranslationMap.get(keyFilterPredicate.operation)); + if (keyFilterPredicate.ignoreCase) { + operation += ' ' + translate.instant('filter.ignore-case'); + } + if (!dynamicValue) { + value = `'${keyFilterPredicate.value.staticValue}'`; + } + break; + case AlarmRuleFilterPredicateType.NUMERIC: + operation = translate.instant(alarmRuleNumericOperationTranslationMap.get(keyFilterPredicate.operation)); + if (!dynamicValue) { + if (keyFilter.valueType === EntityKeyValueType.DATE_TIME) { + value = datePipe.transform(keyFilterPredicate.value.staticValue, 'yyyy-MM-dd HH:mm'); + } else { + value = keyFilterPredicate.value.staticValue + ''; + } + } + break; + case AlarmRuleFilterPredicateType.BOOLEAN: + operation = translate.instant(alarmRuleBooleanOperationTranslationMap.get(keyFilterPredicate.operation)); + if (!dynamicValue) { + value = translate.instant(keyFilterPredicate.value.staticValue ? 'value.true' : 'value.false'); + } + break; + case AlarmRuleFilterPredicateType.NO_DATA: + operation = translate.instant(alarmRuleStringOperationTranslationMap.get(AlarmRuleStringOperation.NO_DATA)); + if (!dynamicValue) { + value = keyFilterPredicate.duration.staticValue + ' ' + translate.instant(timeUnitTranslationMap.get(keyFilterPredicate.unit)).toLowerCase(); + } + break; + } + if (!dynamicValue) { + value = `${value}`; + } + return `${key} ${operation} ${value}` + } + }); + if (predicates.length > 1) { + return '(' + predicates.join(` ${translate.instant(complexOperationTranslationMap.get(filterOperation))} `)+ ')'; + } else { + return predicates.toString(); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html index aa1ac8102b..c0f983e6d0 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html @@ -1,6 +1,6 @@ + +

{{ 'api-key.generate-title' | translate }}

+ +
+ +
+@if (isLoading$ | async) { + +} +
+
+
+ api-key.generate-text +
+ + api-key.description + + + {{ 'asset.description-required' | translate }} + + + + {{ 'api-key.enable' | translate }} + +
+ + api-key.expiration-date + + @for (value of expirationTimeOptions; track value) { + + {{ value | dateExpiration }} + + } + {{'api-key.expiration-time-never' | translate}} + {{'api-key.expiration-time-custom' | translate}} + + + @if (isCustomExpirationTime()) { + + api-key.date + + + + + } +
+
+
+
+ + +
+ diff --git a/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.scss b/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.scss new file mode 100644 index 0000000000..7f05b2ca75 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.scss @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 '../../src/scss/constants'; + +:host { + display: grid; + width: 700px; + height: 100%; + max-width: 100%; + max-height: 100vh; + grid-template-rows: min-content 4px minmax(auto, 1fr) min-content; + + .mat-mdc-dialog-content { + grid-row: 3; + } + .mat-mdc-dialog-actions { + grid-row: 4; + } + .api-key-text { + position: relative; + padding: 8px 16px 8px 16px; + &::before { + content: ''; + position: absolute; + inset: 0; + background-color: $tb-primary-color; + border-radius: 6px; + opacity: 0.04; + } + + span { + font-size: 12px; + color: rgba(0, 0, 0, 0.54); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.ts b/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.ts new file mode 100644 index 0000000000..b51d7607b9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.ts @@ -0,0 +1,103 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + + +import { Component, Inject } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { FormBuilder, Validators } from '@angular/forms'; +import { deepTrim } from '@core/utils'; +import { ApiKeyService } from '@core/http/api-key.service'; +import { ApiKeyInfo } from '@shared/models/api-key.models'; +import { ApiKeysTableDialogData } from '@home/components/api-key/api-keys-table-dialog.component'; +import { DAY } from '@shared/models/time/time.models'; + +@Component({ + selector: 'tb-add-api-key-dialog', + templateUrl: './add-api-key-dialog.component.html', + styleUrls: ['./add-api-key-dialog.component.scss'] +}) +export class AddApiKeyDialogComponent extends DialogComponent { + + readonly startDate = new Date(); + readonly expirationTimeOptions: Array = [7, 30, 60, 90].map(days => days * DAY); + readonly apiKeyForm = this.fb.group({ + description: [{value: null, disabled: false}, [Validators.required]], + enabled: [{value: true, disabled: false}, []], + expirationTime: [{value: this.expirationTimeOptions[1] as string | number, disabled: false}, [Validators.required]], + customExpirationTime: [{value: null, disabled: true}, []], + }); + + constructor( + protected store: Store, + protected router: Router, + public dialogRef: MatDialogRef, + private fb: FormBuilder, + private apiKeyService: ApiKeyService, + @Inject(MAT_DIALOG_DATA) public data: ApiKeysTableDialogData, + ) { + super(store, router, dialogRef); + } + + close(): void { + this.dialogRef.close(null); + } + + add(): void { + const formValue = this.apiKeyForm.value; + const userId = this.data.userId; + const expirationTime = this.calcExpirationTime(); + const apiKey = { + ...deepTrim(formValue), + expirationTime, + userId, + } as ApiKeyInfo; + this.apiKeyService.saveApiKey(apiKey).subscribe( + (res) => { + this.dialogRef.close(res); + } + ); + } + + isCustomExpirationTime() { + return this.apiKeyForm.value?.expirationTime === 'custom'; + } + + onExpirationDateChange() { + const customExpirationTimeControl = this.apiKeyForm.get('customExpirationTime'); + if (this.isCustomExpirationTime()) { + customExpirationTimeControl.enable({emitEvent: false}); + } else { + customExpirationTimeControl.disable({emitEvent: false}); + } + } + + private calcExpirationTime(): number { + const expirationTimeValue = this.apiKeyForm.get('expirationTime').value; + let value: number; + if (this.isCustomExpirationTime()) { + value = this.apiKeyForm.get('customExpirationTime').value.getTime(); + } else if (expirationTimeValue === 'never') { + value = 0; + } else { + value = expirationTimeValue as number + Date.now(); + } + return value; + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.html b/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.html new file mode 100644 index 0000000000..70e7ae7396 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.html @@ -0,0 +1,105 @@ + + +

{{ 'api-key.generated-api-key-title' | translate }}

+ + +
+
+
+
api-key.generated-api-key-copy
+
+ +
+
+
api-key.generated-api-key-command
+ + + + + Windows + + +
+
+
device.connectivity.install-necessary-client-tools
+
device.connectivity.install-curl-windows
+
+ +
+
+
+ + + + MacOS + + +
+
+
device.connectivity.install-necessary-client-tools
+
device.connectivity.install-curl-macos
+
+ +
+
+
+ + + + Linux + + +
+
+
device.connectivity.install-necessary-client-tools
+ +
+ +
+
+
+
+
+
+
+
+ +
+ + +
+
device.connectivity.execute-following-command
+
+ warning + {{ 'api-key.generated-api-key-insecure-url' | translate }} +
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.scss b/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.scss new file mode 100644 index 0000000000..4c4e2e1f30 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.scss @@ -0,0 +1,108 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 '../../src/scss/constants'; + +:host { + display: grid; + height: 100%; + max-width: 100%; + max-height: 100vh; + grid-template-rows: min-content minmax(auto, 1fr) min-content; + + .tb-install-instruction-text { + min-height: 42px; + } + + .insecure-url-warning { + .mat-icon { + color: #FAA405; + } + } + + @media #{$mat-sm} { + width: 470px; + } + + @media #{$mat-gt-sm} { + width: 720px; + } +} + +:host ::ng-deep { + .tb-markdown-view { + .tb-command-code { + .code-wrapper { + padding: 0; + pre[class*=language-] { + margin: 0; + background: #F3F6FA; + border-color: $tb-primary-color; + padding-right: 38px; + overflow: hidden; + overflow-x: auto; + padding-bottom: 4px; + min-height: 42px; + scrollbar-width: thin; + + &::-webkit-scrollbar { + width: 4px; + height: 4px; + } + } + } + button.clipboard-btn { + right: -2px; + p { + color: $tb-primary-color; + } + p, div { + background-color: #F3F6FA; + } + div { + img { + display: none; + } + &:after { + content: ""; + position: initial; + display: block; + width: 18px; + height: 18px; + background: $tb-primary-color; + mask-image: url(/assets/copy-code-icon.svg); + -webkit-mask-image: url(/assets/copy-code-icon.svg); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + } + } + } + } + } + .mdc-button__label > span { + .mat-icon { + vertical-align: text-bottom; + box-sizing: initial; + } + } + + .tabs-icon { + margin-right: 8px; + } + + .tb-form-panel.tb-tab-body { + padding: 16px 0 0; + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.ts b/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.ts new file mode 100644 index 0000000000..a6fed8376e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.ts @@ -0,0 +1,80 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { userInfoCommand, ApiKey } from '@shared/models/api-key.models'; +import { getOS } from '@core/utils'; + +export interface ApiKeyGeneratedDialogData { + apiKey: ApiKey; +} + +@Component({ + selector: 'tb-api-key-generated-dialog', + templateUrl: './api-key-generated-dialog.component.html', + styleUrls: ['api-key-generated-dialog.component.scss'] +}) +export class ApiKeyGeneratedDialogComponent extends DialogComponent { + + private baseUrl = window.location.origin; + + apiKeyCommand = userInfoCommand(this.baseUrl, this.data.apiKey.value); + secureUrl = this.baseUrl.startsWith('https'); + selectedTab: number; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ApiKeyGeneratedDialogData) { + super(store, router, dialogRef); + this.selectTabIndexForUserOS(); + } + + close(): void { + this.dialogRef.close(null); + } + + createMarkDownCommand(command: string): string { + return '```bash\n' + + command + + '{:copy-code}\n' + + '```'; + } + + private selectTabIndexForUserOS() { + const currentOS = getOS(); + switch (currentOS) { + case 'linux': + case 'android': + this.selectedTab = 2; + break; + case 'macos': + case 'ios': + this.selectedTab = 1; + break; + case 'windows': + this.selectedTab = 0; + break; + default: + this.selectedTab = 2; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts new file mode 100644 index 0000000000..b2b26ee51e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts @@ -0,0 +1,211 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig, + CellActionDescriptor +} from '@home/models/entity/entities-table-config.models'; +import { EntityType, EntityTypeResource, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { Direction } from '@shared/models/page/sort-order'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Injectable, Renderer2, ViewContainerRef } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { Observable } from 'rxjs'; +import { ApiKeyInfo, ApiKey } from '@shared/models/api-key.models'; +import { ApiKeyService } from '@core/http/api-key.service'; +import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { map } from 'rxjs/operators'; +import { UserId } from '@shared/models/id/user-id'; +import { AddApiKeyDialogComponent } from '@home/components/api-key/add-api-key-dialog.component'; +import { EditApiKeyDescriptionPanelComponent } from '@home/components/api-key/edit-api-key-description-panel.component'; +import { ApiKeysTableDialogData } from '@home/components/api-key/api-keys-table-dialog.component'; +import { + ApiKeyGeneratedDialogComponent, + ApiKeyGeneratedDialogData +} from '@home/components/api-key/api-key-generated-dialog.component'; + +@Injectable() +export class ApiKeysTableConfig extends EntityTableConfig { + + constructor( + private apiKeyService: ApiKeyService, + private translate: TranslateService, + private customTranslate: CustomTranslatePipe, + private dialog: MatDialog, + private datePipe: DatePipe, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private userId: UserId, + ) { + super(); + + this.entityType = EntityType.API_KEY; + this.detailsPanelEnabled = false; + this.addAsTextButton = true; + this.pageMode = false; + + this.entityTranslations = entityTypeTranslations.get(EntityType.API_KEY); + this.entityResources = {} as EntityTypeResource; + this.tableTitle = this.translate.instant('api-key.api-keys'); + this.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; + + this.entitiesFetchFunction = pageLink => this.apiKeyService.getUserApiKeys(this.userId.id, pageLink); + this.addEntity = () => this.addApiKey(); + + this.deleteEntityTitle = entity => this.translate.instant('api-key.delete-api-key-title', {name: entity.description}); + this.deleteEntityContent = () => this.translate.instant('api-key.delete-api-key-text'); + this.deleteEntitiesTitle = count => this.translate.instant('api-key.delete-api-keys-title', {count}); + this.deleteEntitiesContent = () => this.translate.instant('api-key.delete-api-keys-text'); + this.deleteEntity = id => this.apiKeyService.deleteApiKey(id.id); + + this.cellActionDescriptors = this.configureCellActions(); + this.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '170px'), + new EntityTableColumn('description', 'api-key.description', '100%', + (entity) => this.customTranslate.transform(entity?.description), () => ({}), true, () => ({}), + (entity) => entity?.description.length > 40 ? this.customTranslate.transform(entity.description) : undefined, false, + { + name: this.translate.instant('api-key.edit-description'), + icon: 'edit', + isEnabled: () => true, + onAction: ($event, entity) => this.updateApiKeyDescription($event, entity) + }), + new EntityTableColumn('active', 'api-key.status', '80px', + entity => this.apiKeyStatus(entity), entity => this.apiKeyStatusStyle(entity), false), + new EntityTableColumn('expirationTime', 'api-key.expiration-time', '120px', + (entity) => entity.expirationTime != 0 ? + this.datePipe.transform(entity.expirationTime, 'dd/MM/yyyy, HH:mm') : + this.translate.instant('api-key.expiration-time-never'), + ), + ); + } + + private configureCellActions(): Array> { + const actions: Array> = []; + actions.push( + { + name: '', + nameFunction: (entity) => this.translate.instant(entity.enabled ? 'api-key.disable' : 'api-key.enable'), + icon: 'mdi:toggle-switch', + isEnabled: (entity) => !entity.expired, + iconFunction: (entity) => entity.enabled ? 'mdi:toggle-switch' : 'mdi:toggle-switch-off-outline', + onAction: ($event, entity) => this.toggleEnableMode($event, entity) + } + ) + return actions; + } + + private addApiKey(): Observable { + return this.dialog.open(AddApiKeyDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + userId: this.userId + } + }).afterClosed().pipe(map(res => { + if (res) { + this.apiKeyGenerated(res); + } else { + return null; + } + })); + } + + private apiKeyGenerated(apiKey: ApiKey) { + this.dialog.open(ApiKeyGeneratedDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + apiKey + } + }).afterClosed() + .subscribe(() => { + this.updateData(); + }); + } + + private toggleEnableMode($event: Event, entity: ApiKeyInfo): void { + if ($event) { + $event.stopPropagation(); + } + this.apiKeyService.enableApiKey(entity.id.id, !entity.enabled, {ignoreLoading: true}) + .subscribe( + () => this.updateData() + ); + } + + private apiKeyStatus(apiKey: ApiKeyInfo): string { + let translateKey = 'api-key.status-active'; + let backgroundColor = 'rgba(25, 128, 56, 0.08)'; + if (apiKey.expired) { + translateKey = 'api-key.status-expired'; + backgroundColor = 'rgba(0, 0, 0, 0.04)'; + } else if (!apiKey.enabled) { + translateKey = 'api-key.status-inactive'; + backgroundColor = 'rgba(209, 39, 48, 0.08)'; + } + return `
+ ${this.translate.instant(translateKey)} +
`; + } + + private apiKeyStatusStyle(apiKey: ApiKeyInfo): object { + const styleObj = { + fontSize: '14px', + color: '#198038', + cursor: 'pointer' + }; + if (apiKey.expired) { + styleObj.color = 'rgba(0, 0, 0, 0.54)'; + } else if (!apiKey.enabled) { + styleObj.color = '#d12730'; + } + return styleObj; + } + + private updateApiKeyDescription($event: Event, entity: ApiKeyInfo) { + if ($event) { + $event.stopPropagation(); + } + const trigger = ($event.target || $event.srcElement || $event.currentTarget) as Element; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const editSecretDescriptionPanelPopover = this.popoverService.displayPopover({ + trigger, + renderer: this.renderer, + componentType: EditApiKeyDescriptionPanelComponent, + hostView: this.viewContainerRef, + preferredPlacement: ['right', 'bottom', 'top'], + context: { + apiKeyId: entity.id.id, + description: entity.description + }, + isModal: true + }); + editSecretDescriptionPanelPopover.tbComponentRef.instance.descriptionApplied.subscribe(() => { + editSecretDescriptionPanelPopover.hide(); + this.updateData(); + }); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.html b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.html new file mode 100644 index 0000000000..22aea2c7a2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.html @@ -0,0 +1,39 @@ + +
+ +

{{ 'api-key.manage-api-keys' | translate }}

+ +
+ +
+
+ +
+
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.scss b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.scss new file mode 100644 index 0000000000..ebd123b857 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.scss @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + .api-keys-dialog-container { + width: 1080px; + max-width: 100%; + + .api-keys-dialog-content { + height: 65vh; + border-radius: 0; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.ts b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.ts new file mode 100644 index 0000000000..aaba7b9f1b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.ts @@ -0,0 +1,47 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + + +import { Component, Inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { UserId } from '@shared/models/id/user-id'; + +export interface ApiKeysTableDialogData { + userId: UserId; +} + +@Component({ + selector: 'tb-api-keys-table-dialog', + templateUrl: './api-keys-table-dialog.component.html', + styleUrls: ['api-keys-table-dialog.component.scss'] +}) +export class ApiKeysTableDialogComponent { + + constructor( + protected store: Store, + protected router: Router, + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ApiKeysTableDialogData, + ) { + } + + close(): void { + this.dialogRef.close(null); + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.html b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.html new file mode 100644 index 0000000000..969ae31ad5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.html @@ -0,0 +1,20 @@ + +@if (apiKeysTableConfig) { + +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.scss b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.scss new file mode 100644 index 0000000000..78646c14fe --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.scss @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 '../../src/scss/constants.scss'; +@import '../../src/scss/mixins'; + +:host ::ng-deep { + tb-entities-table { + .mat-drawer-container { + background-color: white; + + mat-cell.mat-column-description { + white-space: nowrap; + .mat-mdc-icon-button { + vertical-align: middle; + margin-left: 8px; + @include tb-mat-icon-button-size(32); + .mat-icon { + @include tb-mat-icon-size(20); + } + } + span { + display: inline-block; + max-width: 40ch; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + pointer-events: none; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.ts b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.ts new file mode 100644 index 0000000000..18730803a4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.ts @@ -0,0 +1,80 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + effect, + input, + Renderer2, + ViewChild, + ViewContainerRef, +} from '@angular/core'; +import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { DatePipe } from '@angular/common'; +import { ApiKeysTableConfig } from '@home/components/api-key/api-keys-table-config'; +import { ApiKeyService } from '@core/http/api-key.service'; +import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { UserId } from '@shared/models/id/user-id'; + +@Component({ + selector: 'tb-api-keys-table', + templateUrl: './api-keys-table.component.html', + styleUrls: ['./api-keys-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ApiKeysTableComponent { + + @ViewChild(EntitiesTableComponent, {static: true}) entitiesTable: EntitiesTableComponent; + + active = input(); + userId = input(); + + apiKeysTableConfig: ApiKeysTableConfig; + + constructor( + private apiKeyService: ApiKeyService, + private translate: TranslateService, + private customTranslate: CustomTranslatePipe, + private dialog: MatDialog, + private datePipe: DatePipe, + private cd: ChangeDetectorRef, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + ) { + effect(() => { + if (this.active()) { + this.apiKeysTableConfig = new ApiKeysTableConfig( + this.apiKeyService, + this.translate, + this.customTranslate, + this.dialog, + this.datePipe, + this.popoverService, + this.renderer, + this.viewContainerRef, + this.userId(), + ); + this.cd.markForCheck(); + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.html b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.html new file mode 100644 index 0000000000..1a3f159af4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.html @@ -0,0 +1,43 @@ + +
+
{{ 'api-key.edit-description' | translate }}
+ + api-key.description + + + {{ 'asset.description-required' | translate }} + + {{ input.value?.length || 0 }}/255 + +
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.scss b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.scss new file mode 100644 index 0000000000..5dcf029470 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.scss @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.tb-edit-api-key-description-panel { + --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + + width: 400px; + max-width: 90vw; + display: flex; + flex-direction: column; + gap: 16px; + .tb-edit-api-key-description-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-edit-api-key-description-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.ts b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.ts new file mode 100644 index 0000000000..1614ea1ed3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.ts @@ -0,0 +1,61 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + + +import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { FormBuilder, Validators } from '@angular/forms'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { ApiKeyService } from '@core/http/api-key.service'; + +@Component({ + selector: 'tb-edit-api-key-description-panel', + templateUrl: './edit-api-key-description-panel.component.html', + styleUrls: ['./edit-api-key-description-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class EditApiKeyDescriptionPanelComponent implements OnInit { + + @Input() + apiKeyId: string; + + @Input() + description: string; + + @Output() + descriptionApplied = new EventEmitter(); + + descriptionFormControl = this.fb.control(null, Validators.required); + + constructor(private fb: FormBuilder, + private popover: TbPopoverComponent, + private apiKeyService: ApiKeyService) {} + + ngOnInit(): void { + this.descriptionFormControl.setValue(this.description, {emitEvent: false}); + } + + cancel() { + this.popover.hide(); + } + + applyDescription() { + const description = this.descriptionFormControl.value.trim(); + this.apiKeyService.updateApiKeyDescription(this.apiKeyId, description).subscribe(() => { + this.descriptionApplied.emit(description); + }); + } + +} 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..fa791d479e 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 @@ -1,6 +1,6 @@ + + +
+
+
+
audit-log.filter-types
+ + +
+
+
+ + + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-filter.component.scss b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-filter.component.scss new file mode 100644 index 0000000000..501a5e2419 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-filter.component.scss @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + display: flex; + max-width: 100%; + padding-right: 20px; + .mdc-button { + max-width: 100%; + } +} + +:host ::ng-deep { + .mdc-button { + .mat-icon { + min-width: 24px; + } + .mdc-button__label { + overflow: hidden; + text-overflow: ellipsis; + } + } +} + +::ng-deep { + .tb-audit-log-config-component { + max-width: 100%; + width: 600px; + min-width: 100%; + flex: 1; + } +} diff --git a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-filter.component.ts b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-filter.component.ts new file mode 100644 index 0000000000..98e76c6fc7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-filter.component.ts @@ -0,0 +1,211 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { TemplatePortal } from '@angular/cdk/portal'; +import { + ChangeDetectorRef, + Component, + DestroyRef, + ElementRef, + forwardRef, + Input, + OnInit, + TemplateRef, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { isDefined, isDefinedAndNotNull } from '@app/core/utils'; +import { StringItemsOption } from '@app/shared/components/string-items-list.component'; +import { + ActionType, + actionTypeTranslations, + AuditLogFilter, + auditLogFilterEquals, + POSITION_MAP, +} from '@app/shared/public-api'; +import { TranslateService } from '@ngx-translate/core'; + +// @dynamic +@Component({ + selector: 'tb-audit-log-filter', + templateUrl: './audit-log-filter.component.html', + styleUrls: ['./audit-log-filter.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AuditLogFilterComponent), + multi: true + } + ] +}) +export class AuditLogFilterComponent implements OnInit, ControlValueAccessor { + + @ViewChild('auditLogFilterPanel') + auditLogFilterPanel: TemplateRef; + + @Input() disabled: boolean; + + ActionType = ActionType; + + actionTypes = Object.values(ActionType); + + actionTypeTranslations = actionTypeTranslations; + + buttonDisplayValue = this.translate.instant('audit-log.audit-log-filter-title'); + + auditLogFilterForm: FormGroup; + + auditLogOverlayRef: OverlayRef; + + initialAuditLogFilter: AuditLogFilter; + + private auditLogFilter: AuditLogFilter; + + private propagateChange = (_: any) => {}; + + constructor(private fb: FormBuilder, + private translate: TranslateService, + private overlay: Overlay, + private nativeElement: ElementRef, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef, + private destroyRef: DestroyRef) {} + + ngOnInit(): void { + this.auditLogFilterForm = this.fb.group({ + actionTypes: [[]] + }); + this.auditLogFilterForm.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => { + this.updateValidators(); + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.auditLogFilterForm.disable({emitEvent: false}); + } else { + this.auditLogFilterForm.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(auditLogFilter?: AuditLogFilter): void { + this.auditLogFilter = auditLogFilter; + if(!this.initialAuditLogFilter && isDefined(this.auditLogFilter)) { + this.initialAuditLogFilter = this.auditLogFilterForm.getRawValue(); + } + this.updateButtonDisplayValue(); + this.updateAuditLogFilterForm(auditLogFilter); + } + + get predefinedTypeValues (): StringItemsOption[] { + return [...this.actionTypes].map(type => ({ + name: this.translate.instant(this.actionTypeTranslations.get(type)), + value: type + })); + } + + private updateValidators() { + } + + toggleAuditLogFilterPanel($event: Event) { + if ($event) { + $event.stopPropagation(); + } + const config = new OverlayConfig({ + panelClass: 'tb-filter-panel', + backdropClass: 'cdk-overlay-transparent-backdrop', + hasBackdrop: true, + maxHeight: '80vh', + height: 'min-content', + minWidth: '' + }); + config.hasBackdrop = true; + config.positionStrategy = this.overlay.position() + .flexibleConnectedTo(this.nativeElement) + .withPositions([POSITION_MAP.bottomLeft]); + + this.auditLogOverlayRef = this.overlay.create(config); + this.auditLogOverlayRef.backdropClick().subscribe(() => { + this.auditLogOverlayRef.dispose(); + }); + this.auditLogOverlayRef.attach(new TemplatePortal(this.auditLogFilterPanel, + this.viewContainerRef)); + } + + cancel() { + this.updateAuditLogFilterForm(this.auditLogFilter); + this.auditLogFilterForm.markAsPristine(); + this.auditLogOverlayRef.dispose(); + } + + update() { + this.auditLogFilterUpdated(this.auditLogFilterForm.value); + this.auditLogFilterForm.markAsPristine(); + this.auditLogOverlayRef.dispose(); + } + + reset() { + if (this.initialAuditLogFilter) { + if (!auditLogFilterEquals(this.auditLogFilterForm.value, this.initialAuditLogFilter)) { + this.updateAuditLogFilterForm(this.initialAuditLogFilter); + this.auditLogFilterForm.markAsDirty(); + } + } + } + + private updateAuditLogFilterForm(auditLogFilter?: AuditLogFilter) { + this.auditLogFilterForm.patchValue({ + actionTypes: auditLogFilter?.actionTypes, + }, {emitEvent: false}); + this.updateValidators(); + } + + private auditLogFilterUpdated(auditLogFilter: AuditLogFilter) { + this.auditLogFilter = auditLogFilter; + this.updateButtonDisplayValue(); + this.propagateChange(this.auditLogFilter); + } + + private updateButtonDisplayValue() { + const filterTextParts: string[] = []; + if (isDefinedAndNotNull(this.auditLogFilter?.actionTypes)) { + const actionTypes = this.auditLogFilter.actionTypes; + actionTypes.forEach(actionType => filterTextParts.push(this.translate.instant(this.actionTypeTranslations.get(actionType as ActionType)))) + } + if (!filterTextParts.length) { + this.buttonDisplayValue = this.translate.instant('audit-log.audit-log-filter-title'); + } else { + this.buttonDisplayValue = this.translate.instant('audit-log.filter-title') + `: ${filterTextParts.join(', ')}`; + } + this.cd.detectChanges(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-header.component.html b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-header.component.html new file mode 100644 index 0000000000..c82257803d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-header.component.html @@ -0,0 +1,20 @@ + + diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldInitService.java b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-header.component.scss similarity index 80% rename from application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldInitService.java rename to ui-ngx/src/app/modules/home/components/audit-log/audit-log-header.component.scss index 6505dae581..0d057f16ca 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldInitService.java +++ b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-header.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.cf; -public interface CalculatedFieldInitService { +:host { + padding-right: 8px; + overflow: hidden; + max-width: 100%; } diff --git a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-header.component.ts b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-header.component.ts new file mode 100644 index 0000000000..8b8009a838 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-header.component.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 } from '@angular/core'; +import { AppState } from '@app/core/public-api'; +import { AuditLog, AuditLogFilter, TimePageLink } from '@app/shared/public-api'; +import { EntityTableHeaderComponent } from '@home/components/entity/entity-table-header.component'; +import { Store } from '@ngrx/store'; + +@Component({ + selector: 'tb-audit-log-header', + templateUrl: './audit-log-header.component.html', + styles: `` +}) +export class AuditLogHeaderComponent extends EntityTableHeaderComponent { + + constructor(protected store: Store) { + super(store) + } + + auditLogFiltersChanged(auditLogFilter: AuditLogFilter) { + this.entitiesTableConfig.componentsData.auditLogFilter = auditLogFilter; + this.entitiesTableConfig.getTable().resetSortAndFilter(true, true); + } +} diff --git a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-table-config.ts b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-table-config.ts index 7055ace090..a5905fd13c 100644 --- a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-table-config.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -25,7 +25,8 @@ import { ActionType, actionTypeTranslations, AuditLog, - AuditLogMode + AuditLogMode, + AuditLogFilter } from '@shared/models/audit-log.models'; import { AliasEntityType, @@ -48,6 +49,8 @@ import { AuditLogDetailsDialogComponent, AuditLogDetailsDialogData } from '@home/components/audit-log/audit-log-details-dialog.component'; +import { deepClone } from '@app/core/utils'; +import { AuditLogHeaderComponent } from '@home/components/audit-log/audit-log-header.component'; export class AuditLogTableConfig extends EntityTableConfig { @@ -71,6 +74,7 @@ export class AuditLogTableConfig extends EntityTableConfig; + this.componentsData = { + auditLogFilter: [] + }; + this.entitiesFetchFunction = pageLink => this.fetchAuditLogs(pageLink); this.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; @@ -139,15 +147,16 @@ export class AuditLogTableConfig extends EntityTableConfig> { + const auditLogFilter: AuditLogFilter = deepClone(this.componentsData?.auditLogFilter) || {}; switch (this.auditLogMode) { case AuditLogMode.TENANT: - return this.auditLogService.getAuditLogs(pageLink); + return this.auditLogService.getAuditLogs(pageLink, auditLogFilter); case AuditLogMode.ENTITY: - return this.auditLogService.getAuditLogsByEntityId(this.entityId, pageLink); + return this.auditLogService.getAuditLogsByEntityId(this.entityId, pageLink, auditLogFilter); case AuditLogMode.USER: - return this.auditLogService.getAuditLogsByUserId(this.userId.id, pageLink); + return this.auditLogService.getAuditLogsByUserId(this.userId.id, pageLink, auditLogFilter); case AuditLogMode.CUSTOMER: - return this.auditLogService.getAuditLogsByCustomerId(this.customerId.id, pageLink); + return this.auditLogService.getAuditLogsByCustomerId(this.customerId.id, pageLink, auditLogFilter); } } diff --git a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-table.component.html b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-table.component.html index d48e25e430..1891111507 100644 --- a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-table.component.html +++ b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-table.component.html @@ -1,6 +1,6 @@ +
+ + + +
+
+
+
+
{{ 'common.general' | translate }}
+
+ + {{ 'entity-field.title' | translate }} + + @if (entityForm.get('name').errors && entityForm.get('name').touched) { + + @if (entityForm.get('name').hasError('required')) { + {{ 'common.hint.title-required' | translate }} + } @else if (entityForm.get('name').hasError('pattern')) { + {{ 'common.hint.title-pattern' | translate }} + } @else if (entityForm.get('name').hasError('maxlength')) { + {{ 'common.hint.title-max-length' | translate }} + } + + } + + + +
+ + + + {{ 'common.type' | translate }} + + + {{ CalculatedFieldTypeTranslations.has(entityForm.get('type').value) + ? (CalculatedFieldTypeTranslations.get(entityForm.get('type').value).name | translate) + : entityForm.get('type').value }} + + @for (type of fieldTypes; track type) { + + + {{ CalculatedFieldTypeTranslations.get(type).name | translate }} + +
+ + {{ CalculatedFieldTypeTranslations.get(type).hint | translate }} + +
+ } +
+
+
+ @switch (entityForm.get('type').value) { + @case (CalculatedFieldType.GEOFENCING) { + + } + @case (CalculatedFieldType.PROPAGATION) { + + } + @case (CalculatedFieldType.RELATED_ENTITIES_AGGREGATION) { + + } + @case (CalculatedFieldType.ENTITY_AGGREGATION) { + + } + @default { + + } + } +
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.scss new file mode 100644 index 0000000000..695df5063a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.scss @@ -0,0 +1,52 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +::ng-deep { + .tbel-script-lang-chip { + line-height: 20px; + font-size: 14px; + font-weight: 500; + color: white; + border-radius: 100px; + width: 70px; + min-width: 70px; + display: flex; + justify-content: center; + margin-top: 2px; + margin-right: 4px; + } + + .tb-js-func { + .ace_tb { + &.ace_calculated-field { + &-ctx { + color: #C52F00; + } + &-args { + color: #185F2A; + } + &-key { + color: #c24c1a; + } + &-time-window, &-values, &-func, &-value, &-ts, &-latestTs { + color: #7214D0; + } + &-start-ts, &-end-ts { + color: #2CAA00; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.ts new file mode 100644 index 0000000000..425069239f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.ts @@ -0,0 +1,168 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, inject, Inject, Input } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityComponent } from '../../components/entity/entity.component'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { EntityType } from '@shared/models/entity-type.models'; +import { TranslateService } from '@ngx-translate/core'; +import { + CalculatedFieldConfiguration, + CalculatedFieldInfo, + calculatedFieldsEntityTypeList, + CalculatedFieldType, + calculatedFieldTypes, + CalculatedFieldTypeTranslations +} from '@shared/models/calculated-field.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { BaseData } from '@shared/models/base-data'; +import { Observable } from 'rxjs'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { + CalculatedFieldsTableConfig, + CalculatedFieldsTableEntity +} from '@home/components/calculated-fields/calculated-fields-table-config'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { CalculatedFieldFormService } from '@core/services/calculated-field-form.service'; +import { AssetInfo } from '@shared/models/asset.models'; +import { DeviceInfo } from '@shared/models/device.models'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { EntityService } from '@core/http/entity.service'; + +@Component({ + selector: 'tb-calculated-field', + templateUrl: './calculated-field.component.html', + styleUrls: ['./calculated-field.component.scss'] +}) +export class CalculatedFieldComponent extends EntityComponent { + + @Input() + standalone = false; + + @Input() + entityName: string; + + disabledConfiguration = false; + + ownerId = new TenantId(getCurrentAuthUser(this.store).tenantId); + readonly tenantId = getCurrentAuthUser(this.store).tenantId; + readonly EntityType = EntityType; + readonly calculatedFieldsEntityTypeList = calculatedFieldsEntityTypeList; + readonly CalculatedFieldType = CalculatedFieldType; + readonly fieldTypes = calculatedFieldTypes; + readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations; + + private cfFormService = inject(CalculatedFieldFormService); + private destroyRef = inject(DestroyRef); + + constructor(protected store: Store, + protected translate: TranslateService, + @Inject('entity') protected entityValue: CalculatedFieldInfo, + @Inject('entitiesTableConfig') protected entitiesTableConfigValue: CalculatedFieldsTableConfig, + protected fb: FormBuilder, + protected cd: ChangeDetectorRef, + private entityService: EntityService) { + super(store, fb, entityValue, entitiesTableConfigValue, cd); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + additionalDebugActionConfig = { + ...this.entitiesTableConfig.additionalDebugActionConfig, + action: () => this.entitiesTableConfig.additionalDebugActionConfig.action( + { id: this.entity.id, ...this.entityFormValue() }, false, + (expression) => { + if (expression) { + this.entityForm.get('configuration').setValue({...this.entityFormValue().configuration, expression}); + this.entityForm.get('configuration').markAsDirty(); + } + }), + }; + + get entityId(): EntityId { + return this.entityForm.get('entityId').value; + } + + get entitiesTableConfig(): CalculatedFieldsTableConfig { + return this.entitiesTableConfigValue; + } + + changeEntity(entity: BaseData): void { + this.entityName = entity?.name; + } + + buildForm(_entity?: CalculatedFieldInfo): FormGroup { + const form = inject(CalculatedFieldFormService).buildForm(); + inject(CalculatedFieldFormService).setupTypeChange(form, inject(DestroyRef), () => this.isEditValue); + return form; + } + + updateForm(entity: CalculatedFieldInfo) { + const { configuration = {} as CalculatedFieldConfiguration, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, entityId = this.entityId, ...value } = entity ?? {}; + const preparedConfig = this.cfFormService.prepareConfig(configuration); + this.entityForm.patchValue({ type }, {emitEvent: false, onlySelf: true}); + setTimeout(() => { + this.entityForm.patchValue({ configuration: preparedConfig, debugSettings, entityId, ...value }, {emitEvent: false}); + this.entityForm.get('type').updateValueAndValidity(); + }); + } + + onTestScript(expression?: string): Observable { + return this.cfFormService.testScript( + this.entity?.id?.id, + this.entityValue, + this.entitiesTableConfig.getTestScriptDialog.bind(this.entitiesTableConfig), + this.destroyRef, + expression + ); + } + + updateFormState() { + if (this.entityForm) { + if (this.isEditValue) { + this.entityForm.enable({emitEvent: false}); + this.entityForm.get('entityId').disable({emitEvent: false}); + this.getOwnerId(this.entityId); + } else { + this.entityForm.disable({emitEvent: false}); + } + } + } + + getOwnerId(entityId: EntityId) { + if (entityId?.entityType === EntityType.DEVICE || entityId?.entityType === EntityType.ASSET) { + this.entityService.getEntity(entityId.entityType, entityId.id, { ignoreLoading: true, ignoreErrors: true }).subscribe( + (entity: AssetInfo | DeviceInfo) => { + if (this.isAssignedToCustomer(entity)) { + this.ownerId = entity.customerId; + } + } + ); + } + } + + private isAssignedToCustomer(entity: AssetInfo | DeviceInfo): boolean { + return entity && entity.customerId && entity.customerId.id !== NULL_UUID; + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts new file mode 100644 index 0000000000..c58b595ba1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts @@ -0,0 +1,80 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + CalculatedFieldDialogComponent +} from '@home/components/calculated-fields/components/dialog/calculated-field-dialog.component'; +import { + CalculatedFieldScriptTestDialogComponent +} from '@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component'; +import { + CalculatedFieldTestArgumentsComponent +} from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component'; +import { + EntityDebugSettingsButtonComponent +} from '@home/components/entity/debug/entity-debug-settings-button.component'; +import { + GeofencingConfigurationModule +} from '@home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module'; +import { + SimpleConfigurationModule +} from '@home/components/calculated-fields/components/simple-configuration/simple-configuration.module'; +import { + PropagationConfigurationModule +} from '@home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module'; +import { + RelatedEntitiesAggregationComponentModule +} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module'; +import { + EntityAggregationComponentModule +} from '@home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.module'; +import { + CalculatedFieldsHeaderComponent +} from '@home/components/calculated-fields/table-header/calculated-fields-header.component'; +import { + CalculatedFieldsFilterConfigComponent +} from '@home/components/calculated-fields/table-header/calculated-fields-filter-config.component'; +import { CalculatedFieldComponent } from '@home/components/calculated-fields/calculated-field.component'; + +@NgModule({ + declarations: [ + CalculatedFieldDialogComponent, + CalculatedFieldScriptTestDialogComponent, + CalculatedFieldTestArgumentsComponent, + CalculatedFieldsHeaderComponent, + CalculatedFieldsFilterConfigComponent, + CalculatedFieldComponent, + ], + imports: [ + CommonModule, + SharedModule, + GeofencingConfigurationModule, + EntityDebugSettingsButtonComponent, + SimpleConfigurationModule, + PropagationConfigurationModule, + RelatedEntitiesAggregationComponentModule, + EntityAggregationComponentModule, + ], + exports: [ + CalculatedFieldDialogComponent, + CalculatedFieldScriptTestDialogComponent, + CalculatedFieldComponent, + ] +}) +export class CalculatedFieldsModule {} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts index 07f8f0293c..efb767f92e 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 @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -16,15 +16,16 @@ import { DateEntityTableColumn, + EntityLinkTableColumn, EntityTableColumn, EntityTableConfig } from '@home/models/entity/entities-table-config.models'; -import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; import { TranslateService } from '@ngx-translate/core'; import { Direction } from '@shared/models/page/sort-order'; -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { PageLink } from '@shared/models/page/page-link'; -import { Observable, of } from 'rxjs'; +import { EMPTY, Observable, of } from 'rxjs'; import { PageData } from '@shared/models/page/page-data'; import { EntityId } from '@shared/models/id/entity-id'; import { Store } from '@ngrx/store'; @@ -34,64 +35,100 @@ import { DestroyRef, Renderer2 } from '@angular/core'; import { EntityDebugSettings } from '@shared/models/entity.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; -import { catchError, filter, switchMap, tap } from 'rxjs/operators'; +import { catchError, filter, first, switchMap, tap } from 'rxjs/operators'; import { ArgumentEntityType, ArgumentType, CalculatedField, CalculatedFieldEventArguments, + CalculatedFieldInfo, + CalculatedFieldScriptConfiguration, + CalculatedFieldsQuery, CalculatedFieldType, CalculatedFieldTypeTranslations, + debugCfActionEnabled, getCalculatedFieldArgumentsEditorCompleter, getCalculatedFieldArgumentsHighlights, + PropagationWithExpression, } from '@shared/models/calculated-field.models'; import { - CalculatedFieldDebugDialogComponent, - CalculatedFieldDebugDialogData, CalculatedFieldDialogComponent, CalculatedFieldDialogData, CalculatedFieldScriptTestDialogComponent, CalculatedFieldTestScriptDialogData } from './components/public-api'; import { ImportExportService } from '@shared/import-export/import-export.service'; -import { isObject } from '@core/utils'; +import { deepClone, getEntityDetailsPageURL, isObject } from '@core/utils'; import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; import { DatePipe } from '@angular/common'; +import { UtilsService } from "@core/services/utils.service"; +import { ActionNotificationShow } from "@core/notification/notification.actions"; +import { CalculatedFieldEventBody, DebugEventType, EventType } from '@shared/models/event.models'; +import { EventsDialogComponent, EventsDialogData } from '@home/dialogs/events-dialog.component'; +import { + CalculatedFieldsHeaderComponent +} from '@home/components/calculated-fields/table-header/calculated-fields-header.component'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { CalculatedFieldComponent } from '@home/components/calculated-fields/calculated-field.component'; +import { Router } from '@angular/router'; +import { CalculatedFieldsTabsComponent } from '@home/pages/calculated-fields/calculated-fields-tabs.component'; + +export type CalculatedFieldsTableEntity = CalculatedField | CalculatedFieldInfo; -export class CalculatedFieldsTableConfig extends EntityTableConfig { +export class CalculatedFieldsTableConfig extends EntityTableConfig { readonly tenantId = getCurrentAuthUser(this.store).tenantId; additionalDebugActionConfig = { - title: this.translate.instant('calculated-fields.see-debug-events'), - action: (calculatedField: CalculatedField) => this.openDebugEventsDialog.call(this, calculatedField), + title: this.translate.instant('action.see-debug-events'), + action: (calculatedField: CalculatedFieldsTableEntity, + openCalculatedFieldEdit = true, + afterCloseCallback?: (expression: string) => void) => this.openDebugEventsDialog.call(this, null, calculatedField, openCalculatedFieldEdit, afterCloseCallback), }; + calculatedFieldFilterConfig: CalculatedFieldsQuery; + constructor(private calculatedFieldsService: CalculatedFieldsService, private translate: TranslateService, private dialog: MatDialog, private datePipe: DatePipe, - public entityId: EntityId = null, + private entityId: EntityId = null, private store: Store, private destroyRef: DestroyRef, private renderer: Renderer2, - public entityName: string, + private entityName: string, + private ownerId: EntityId = null, private importExportService: ImportExportService, private entityDebugSettingsService: EntityDebugSettingsService, + private utilsService: UtilsService, + private router: Router, + public pageMode = false, ) { super(); + if (this.pageMode) { + this.headerComponent = CalculatedFieldsHeaderComponent; + + this.entityComponent = CalculatedFieldComponent; + this.entityTabsComponent = CalculatedFieldsTabsComponent; + this.rowPointer = true; + } this.tableTitle = this.translate.instant('entity.type-calculated-fields'); - this.detailsPanelEnabled = false; - this.pageMode = false; + this.detailsPanelEnabled = this.pageMode; this.entityType = EntityType.CALCULATED_FIELD; this.entityTranslations = entityTypeTranslations.get(EntityType.CALCULATED_FIELD); + this.entityResources = entityTypeResources.get(EntityType.CALCULATED_FIELD); this.entitiesFetchFunction = (pageLink: PageLink) => this.fetchCalculatedFields(pageLink); this.addEntity = this.getCalculatedFieldDialog.bind(this); - this.deleteEntityTitle = (field: CalculatedField) => this.translate.instant('calculated-fields.delete-title', {title: field.name}); + this.saveEntity = (cf) => this.calculatedFieldsService.saveCalculatedField(cf); + this.loadEntity = id => this.calculatedFieldsService.getCalculatedFieldById(id.id); + this.deleteEntityTitle = (field) => this.translate.instant('calculated-fields.delete-title', {title: field.name}); this.deleteEntityContent = () => this.translate.instant('calculated-fields.delete-text'); this.deleteEntitiesTitle = count => this.translate.instant('calculated-fields.delete-multiple-title', {count}); this.deleteEntitiesContent = () => this.translate.instant('calculated-fields.delete-multiple-text'); this.deleteEntity = id => this.calculatedFieldsService.deleteCalculatedField(id.id); + + this.onEntityAction = action => this.onCFAction(action); + this.addActionDescriptors = [ { name: this.translate.instant('calculated-fields.create'), @@ -109,34 +146,37 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig('expression', 'calculated-fields.expression', '300px'); - expressionColumn.sortable = false; - expressionColumn.cellContentFunction = entity => { - const expressionLabel = this.getExpressionLabel(entity); - return expressionLabel.length < 45 ? expressionLabel : `${expressionLabel.substring(0, 44)}…`; - } - expressionColumn.cellTooltipFunction = entity => { - const expressionLabel = this.getExpressionLabel(entity); - 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(expressionColumn); + this.columns.push(new EntityTableColumn('name', 'common.name', this.pageMode ? '33%' : '60%', + entity => this.utilsService.customTranslation(entity.name, entity.name))); + if (this.pageMode) { + this.columns.push(new EntityTableColumn('entityType', 'entity.entity-type', '10%', + entity => this.translate.instant(entityTypeTranslations.get(entity.entityId.entityType).type))); + this.columns.push(new EntityLinkTableColumn('entityName', 'entity.entity', '33%', + entity => this.utilsService.customTranslation(entity.entityName, entity.entityName), + entity => getEntityDetailsPageURL(entity.entityId?.id, entity.entityId?.entityType as EntityType), false)); + } + this.columns.push(new EntityTableColumn('type', 'common.type', this.pageMode ? '23%' : '40%', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type).name), () => ({whiteSpace: 'nowrap' }))); this.cellActionDescriptors.push( + { + name: this.translate.instant('action.copy'), + icon: 'content_copy', + isEnabled: () => true, + onAction: ($event, entity) => this.copyCalculatedField($event, entity), + }, { name: this.translate.instant('action.export'), icon: 'file_download', isEnabled: () => true, - onAction: (event$, entity) => this.exportCalculatedField(event$, entity), + onAction: ($event, entity) => this.exportCalculatedField($event, entity), }, { name: this.translate.instant('entity-view.events'), icon: 'mdi:clipboard-text-clock', isEnabled: () => true, - onAction: (_, entity) => this.openDebugEventsDialog(entity), + onAction: ($event, entity) => + this.pageMode ? this.openDebugTab($event, entity) : this.openDebugEventsDialog($event, entity), }, { name: '', @@ -145,37 +185,31 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig true, iconFunction: ({ debugSettings }) => this.entityDebugSettingsService.isDebugActive(debugSettings?.allEnabledUntil) || debugSettings?.failuresEnabled ? 'mdi:bug' : 'mdi:bug-outline', onAction: ($event, entity) => this.onOpenDebugConfig($event, entity), - }, - { + } + ); + if (!this.pageMode) { + this.cellActionDescriptors.push({ name: this.translate.instant('action.edit'), icon: 'edit', isEnabled: () => true, - onAction: (_, entity) => this.editCalculatedField(entity), - } - ); - } - - private getExpressionLabel(entity: CalculatedField): string { - if (entity.type === CalculatedFieldType.SCRIPT) { - return 'function calculate(ctx, ' + Object.keys(entity.configuration.arguments).join(', ') + ')'; - } else { - return entity.configuration.expression; + onAction: ($event, entity) => this.editCalculatedField($event, entity), + }) } } - fetchCalculatedFields(pageLink: PageLink): Observable> { - return this.calculatedFieldsService.getCalculatedFields(this.entityId, pageLink); + fetchCalculatedFields(pageLink: PageLink): Observable> { + return this.pageMode ? + this.calculatedFieldsService.getCalculatedFields(pageLink, this.calculatedFieldFilterConfig): + this.calculatedFieldsService.getCalculatedFieldsByEntityId(this.entityId, pageLink); } - onOpenDebugConfig($event: Event, calculatedField: CalculatedField): void { + private onOpenDebugConfig($event: Event, calculatedField: CalculatedFieldsTableEntity): void { + $event?.stopPropagation(); const { debugSettings = {}, id } = calculatedField; const additionalActionConfig = { ...this.additionalDebugActionConfig, - action: () => this.openDebugEventsDialog(calculatedField) + action: () => this.openDebugEventsDialog($event, calculatedField) }; - if ($event) { - $event.stopPropagation(); - } const { viewContainerRef, renderer } = this.entityDebugSettingsService; if (!viewContainerRef || !renderer) { @@ -193,7 +227,24 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig 1) { + table.entityDetailsPanel.matTabGroup.selectedIndex = 1; + } else { + table.entityDetailsPanel.matTabGroup._tabs.changes.pipe( + first() + ).subscribe(() => { + table.entityDetailsPanel.matTabGroup.selectedIndex = 1; + }) + } + } + } + + private editCalculatedField($event: Event, calculatedField: CalculatedFieldsTableEntity, isDirty = false): void { + $event?.stopPropagation(); this.getCalculatedFieldDialog(calculatedField, 'action.apply', isDirty) .subscribe((res) => { if (res) { @@ -202,19 +253,23 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig { + private getCalculatedFieldDialog(value?: CalculatedFieldsTableEntity, buttonTitle = 'action.add', isDirty = false, disabledSelectType = false): Observable { + const entityId = this.entityId || value?.entityId; + const entityName = this.entityName || (value as CalculatedFieldInfo)?.entityName; return this.dialog.open(CalculatedFieldDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { value, buttonTitle, - entityId: this.entityId, + entityId, + entityName, tenantId: this.tenantId, - entityName: this.entityName, + ownerId: this.ownerId ?? {entityType: EntityType.TENANT, id: this.tenantId}, additionalDebugActionConfig: this.additionalDebugActionConfig, getTestScriptDialogFn: this.getTestScriptDialog.bind(this), isDirty, + disabledSelectType }, enterAnimationDuration: isDirty ? 0 : null, }) @@ -222,32 +277,75 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig(CalculatedFieldDebugDialogComponent, { + private openDebugEventsDialog($event: Event, calculatedField: CalculatedFieldsTableEntity, openCalculatedFieldEdit = true, afterCloseCallback?: (expression: string) => void ): void { + $event?.stopPropagation(); + + const onDebugEventSelected = (event: CalculatedFieldEventBody, dialogRef: MatDialogRef) => { + this.getTestScriptDialog(calculatedField, JSON.parse(event.arguments), openCalculatedFieldEdit) + .subscribe(expression => dialogRef.close(expression)); + }; + + this.dialog.open(EventsDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { + title: 'calculated-fields.debugging', tenantId: this.tenantId, - value: calculatedField, - getTestScriptDialogFn: this.getTestScriptDialog.bind(this), + entityId: calculatedField.id, + debugEventTypes:[DebugEventType.DEBUG_CALCULATED_FIELD], + disabledEventTypes:[EventType.LC_EVENT, EventType.ERROR, EventType.STATS], + defaultEventType: DebugEventType.DEBUG_CALCULATED_FIELD, + onDebugEventSelected, + debugActionDisabled: !debugCfActionEnabled(calculatedField) } }) .afterClosed() - .subscribe(); + .subscribe(value => { + if (afterCloseCallback) { + afterCloseCallback(value) + } + }); } - private exportCalculatedField($event: Event, calculatedField: CalculatedField): void { - if ($event) { - $event.stopPropagation(); - } + private exportCalculatedField($event: Event, calculatedField: CalculatedFieldsTableEntity): void { + $event?.stopPropagation(); this.importExportService.exportCalculatedField(calculatedField.id.id); } + private copyCalculatedField($event: Event, calculatedField: CalculatedField): void { + $event?.stopPropagation(); + const copyCalculatedField = deepClone(calculatedField); + if (this.pageMode) { + copyCalculatedField.entityId = null; + delete (copyCalculatedField as CalculatedFieldInfo).entityName; + } + delete copyCalculatedField.id; + this.getCalculatedFieldDialog(copyCalculatedField, 'action.apply', false) + .subscribe((res) => { + if (res) { + this.updateData(); + } + }); + } + private importCalculatedField(): void { this.importExportService.openCalculatedFieldImportDialog() .pipe( filter(Boolean), - switchMap(calculatedField => this.getCalculatedFieldDialog(this.updateImportedCalculatedField(calculatedField), 'action.add', true)), + switchMap(calculatedField => { + if (calculatedField.type === CalculatedFieldType.ALARM || !CalculatedFieldType[calculatedField.type]) { + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant('calculated-fields.hint.import-invalid-calculated-field-type'), + type: 'error', + verticalPosition: 'top', + horizontalPosition: 'left', + duration: 5000 + })); + return EMPTY; + } + return of(calculatedField); + }), + switchMap(calculatedField => this.getCalculatedFieldDialog(this.updateImportedCalculatedField(calculatedField), 'action.add', true, true)), filter(Boolean), switchMap(calculatedField => this.calculatedFieldsService.saveCalculatedField(calculatedField)), filter(Boolean), @@ -257,13 +355,23 @@ 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; } @@ -276,33 +384,62 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig this.updateData()); } - private getTestScriptDialog(calculatedField: CalculatedField, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true): Observable { - const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { - const type = calculatedField.configuration.arguments[key].refEntityKey.type; - acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) - ? { ...argumentsObj[key], type } - : type === ArgumentType.Rolling ? { values: [], type } : { value: '', type, ts: new Date().getTime() }; - return acc; - }, {}); - return this.dialog.open(CalculatedFieldScriptTestDialogComponent, - { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], - data: { - arguments: resultArguments, - expression: calculatedField.configuration.expression, - argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments), - argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments), - openCalculatedFieldEdit - } - }).afterClosed() - .pipe( - filter(Boolean), - tap(expression => { - if (openCalculatedFieldEdit) { - this.editCalculatedField({ entityId: this.entityId, ...calculatedField, configuration: {...calculatedField.configuration, expression } }, true) + getTestScriptDialog(calculatedField: CalculatedFieldsTableEntity, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true, expression?: string): Observable { + if ( + calculatedField.type === CalculatedFieldType.SCRIPT || + calculatedField.type === CalculatedFieldType.RELATED_ENTITIES_AGGREGATION || + (calculatedField.type === CalculatedFieldType.PROPAGATION && calculatedField.configuration.applyExpressionToResolvedArguments === true) + ) { + const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { + const type = calculatedField.configuration.arguments[key].refEntityKey.type; + acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) + ? {...argumentsObj[key], type} + : type === ArgumentType.Rolling ? {values: [], type} : {value: '', type, ts: new Date().getTime()}; + return acc; + }, {}); + return this.dialog.open(CalculatedFieldScriptTestDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], + data: { + arguments: resultArguments, + expression: expression ?? (calculatedField.configuration as CalculatedFieldScriptConfiguration | PropagationWithExpression).expression, + argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments), + argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments), + openCalculatedFieldEdit } - }), - ); + }).afterClosed() + .pipe( + filter(Boolean), + tap(expression => { + if (openCalculatedFieldEdit) { + this.editCalculatedField(null, { + entityId: this.entityId, ...calculatedField, + configuration: {...calculatedField.configuration, expression} as any + }, true) + } + }), + ); + } else { + return of(null); + } + } + + private openCalculatedField($event: Event, entity: CalculatedFieldsTableEntity) { + $event?.stopPropagation(); + const url = this.router.createUrlTree(['calculatedFields', entity.id.id]); + this.router.navigateByUrl(url); + } + + private onCFAction(action: EntityAction): boolean { + switch (action.action) { + case 'open': + this.openCalculatedField(action.event, action.entity); + return true; + case 'export': + this.exportCalculatedField(action.event, action.entity); + return true; + } + return false; } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.html index df433bc70e..102a334402 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.html @@ -1,6 +1,6 @@ @if (calculatedFieldsTableConfig) { - + } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.scss index 3feb1e7429..175df5b779 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.scss +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,9 +14,7 @@ * limitations under the License. */ :host ::ng-deep { - tb-entities-table { - .mat-drawer-container { - background-color: white; - } + .white-background { + --mat-sidenav-content-background-color: white; } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts index e10c4b301e..15e622c468 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -35,6 +35,8 @@ import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; import { ImportExportService } from '@shared/import-export/import-export.service'; import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; import { DatePipe } from '@angular/common'; +import { UtilsService } from "@core/services/utils.service"; +import { ActivatedRoute, Router } from '@angular/router'; @Component({ selector: 'tb-calculated-fields-table', @@ -50,9 +52,12 @@ export class CalculatedFieldsTableComponent { active = input(); entityId = input(); entityName = input(); + ownerId = input(); calculatedFieldsTableConfig: CalculatedFieldsTableConfig; + pageMode: boolean = false; + constructor(private calculatedFieldsService: CalculatedFieldsService, private translate: TranslateService, private dialog: MatDialog, @@ -62,10 +67,14 @@ export class CalculatedFieldsTableComponent { private renderer: Renderer2, private importExportService: ImportExportService, private entityDebugSettingsService: EntityDebugSettingsService, - private destroyRef: DestroyRef) { - + private utilsService: UtilsService, + private destroyRef: DestroyRef, + private route: ActivatedRoute, + private router: Router + ) { + this.pageMode = !!this.route.snapshot.data.isPage; effect(() => { - if (this.active()) { + if (this.active() || this.pageMode) { this.calculatedFieldsTableConfig = new CalculatedFieldsTableConfig( this.calculatedFieldsService, this.translate, @@ -76,8 +85,12 @@ export class CalculatedFieldsTableComponent { this.destroyRef, this.renderer, this.entityName(), + this.ownerId(), this.importExportService, this.entityDebugSettingsService, + this.utilsService, + this.router, + this.pageMode, ); this.cd.markForCheck(); } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html new file mode 100644 index 0000000000..56f6cb0331 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html @@ -0,0 +1,252 @@ + +
+
{{ 'calculated-fields.argument-settings' | translate }}
+
+ @if (hint) { +
+ {{ hint | translate }} +
+ } +
+ @if (!watchKeyChange) { + + } + + @if (!hiddenEntityTypes) { +
+
{{ 'entity.entity-type' | translate }}
+ + + @for (type of argumentEntityTypes; track type) { + {{ ArgumentEntityTypeTranslations.get(type) | translate }} + } + + @if (argumentType.touched && argumentType.hasError('required')) { + + warning + + } + +
+ } + @if (ArgumentEntityTypeParamsMap.has(entityType)) { +
+
{{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}
+ +
+ } +
+ + @if (!hiddenEntityKeyTypes) { +
+
{{ 'calculated-fields.argument-type' | translate }}
+ + + @for (type of argumentTypes; track type) { + {{ ArgumentTypeTranslations.get(type) | translate }} + } + + @if (refEntityKeyFormGroup.get('type').hasError('required') && refEntityKeyFormGroup.get('type').touched) { + + warning + + } + +
+ } + @if (entityFilter.singleEntity?.id || entityType === ArgumentEntityType.Current || entityType === ArgumentEntityType.Tenant) { + @if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Attribute) { +
+
{{ 'calculated-fields.timeseries-key' | translate }}
+ @if (refEntityKeyFormGroup.get('type').value === ArgumentType.LatestTelemetry) { + + } @else { + + } + + + +
+ } @else { + @if (enableAttributeScopeSelection) { +
+
{{ 'calculated-fields.attribute-scope' | translate }}
+ + + + {{ 'calculated-fields.server-attributes' | translate }} + + + {{ 'calculated-fields.client-attributes' | translate }} + + + {{ 'calculated-fields.shared-attributes' | translate }} + + + +
+ } +
+
{{ 'calculated-fields.attribute-key' | translate }}
+ +
+ } + } +
+ @if (watchKeyChange) { + + } + @if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Rolling) { + @if (!hiddenDefaultValue) { +
+
{{ 'calculated-fields.default-value' | translate }}
+ + + @if (argumentFormGroup.get('defaultValue').touched && argumentFormGroup.get('defaultValue').hasError('required')) { + + warning + + } + +
+ } + } @else { +
+
{{ 'calculated-fields.time-window' | translate }}
+ +
+ @if (maxDataPointsPerRollingArg) { +
+
{{ 'calculated-fields.limit' | translate }}
+
+ + + + + + +
+
+ } + } +
+
+
+ + @if (!readonly) { + + } +
+
+ + +
+
{{ label | translate }}
+ + + @if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('required')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('duplicateName')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('pattern')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('maxlength')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('forbiddenName')) { + + warning + + } + +
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.scss similarity index 75% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.scss index 773489ee60..36280dafb7 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,22 +15,9 @@ */ @use '../../../../../../../scss/constants' as constants; -$panel-width: 520px; - :host { - display: flex; - width: $panel-width; - max-width: 100%; - max-height: 100vh; - - .fixed-title-width { - @media #{constants.$mat-xs} { - min-width: 120px; - } - } - .limit-field-row { - @media screen and (max-width: $panel-width) { + @media screen and (max-width: 520px) { display: flex; flex-direction: column; diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts similarity index 56% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts index 6f46e47af9..37362a29ee 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -14,9 +14,18 @@ /// limitations under the License. /// -import { AfterViewInit, ChangeDetectorRef, Component, Input, OnInit, output, ViewChild } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectorRef, + Component, + DestroyRef, + Input, + OnInit, + output, + ViewChild +} from '@angular/core'; import { TbPopoverComponent } from '@shared/components/popover.component'; -import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; import { ArgumentEntityType, @@ -25,10 +34,12 @@ import { ArgumentType, ArgumentTypeTranslations, CalculatedFieldArgumentValue, - CalculatedFieldType, - getCalculatedFieldCurrentEntityFilter + FORBIDDEN_NAMES, + forbiddenNamesValidator, + getCalculatedFieldCurrentEntityFilter, + uniqueNameValidator } from '@shared/models/calculated-field.models'; -import { debounceTime, delay, distinctUntilChanged, filter } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'; import { EntityType } from '@shared/models/entity-type.models'; import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { DatasourceType } from '@shared/models/widget.models'; @@ -43,11 +54,12 @@ import { AppState } from '@core/core.state'; import { Store } from '@ngrx/store'; import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component'; import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { TenantId } from '@shared/models/id/tenant-id'; @Component({ selector: 'tb-calculated-field-argument-panel', templateUrl: './calculated-field-argument-panel.component.html', - styleUrls: ['./calculated-field-argument-panel.component.scss'] + styleUrls: ['../common/calculated-field-panel.scss', './calculated-field-argument-panel.component.scss'] }) export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewInit { @@ -56,22 +68,40 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI @Input() entityId: EntityId; @Input() tenantId: string; @Input() entityName: string; - @Input() calculatedFieldType: CalculatedFieldType; + @Input() ownerId: EntityId; + @Input() isScript: boolean; @Input() usedArgumentNames: string[]; + @Input() watchKeyChange = false; + @Input() hiddenEntityTypes = false; + @Input() hiddenEntityKeyTypes = false; + @Input() hiddenDefaultValue = false; + @Input() defaultValueRequired = false; + @Input() hint: string; + @Input() predefinedEntityFilter: EntityFilter; + @Input() forbiddenNames = FORBIDDEN_NAMES; + @Input() argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[]; + @Input() argumentNameContext: {[key: string]: string} = { + label: 'calculated-fields.argument-name', + required: 'calculated-fields.hint.argument-name-required', + duplicate: 'calculated-fields.hint.argument-name-duplicate', + pattern: 'calculated-fields.hint.argument-name-pattern', + maxlength: 'calculated-fields.hint.argument-name-max-length', + forbidden: 'calculated-fields.hint.argument-name-forbidden' + }; + @Input() readonly = false; @ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent; argumentsDataApplied = output(); + argumentType = this.fb.control(ArgumentEntityType.Current, Validators.required); + readonly maxDataPointsPerRollingArg = getCurrentAuthState(this.store).maxDataPointsPerRollingArg; readonly defaultLimit = Math.floor(this.maxDataPointsPerRollingArg / 10); argumentFormGroup = this.fb.group({ - argumentName: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenArgumentNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], - refEntityId: this.fb.group({ - entityType: [ArgumentEntityType.Current], - id: [''] - }), + argumentName: ['', [Validators.required, Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], + refEntityId: [null], refEntityKey: this.fb.group({ type: [ArgumentType.LatestTelemetry, [Validators.required]], key: ['', [Validators.pattern(oneSpaceInsideRegex)]], @@ -86,7 +116,8 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI entityFilter: EntityFilter; entityNameSubject = new BehaviorSubject(null); - readonly argumentEntityTypes = Object.values(ArgumentEntityType) as ArgumentEntityType[]; + enableAutocomplete = false; + readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations; readonly ArgumentType = ArgumentType; readonly DataKeyType = DataKeyType; @@ -103,20 +134,16 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI private fb: FormBuilder, private cd: ChangeDetectorRef, private popover: TbPopoverComponent, - private store: Store + private store: Store, + private destroyRef: DestroyRef ) { this.observeEntityFilterChanges(); - this.observeEntityTypeChanges(); + this.observeArgumentTypeChanges(); this.observeEntityKeyChanges(); - this.observeUpdatePosition(); } get entityType(): ArgumentEntityType { - return this.argumentFormGroup.get('refEntityId').get('entityType').value; - } - - get refEntityIdFormGroup(): FormGroup { - return this.argumentFormGroup.get('refEntityId') as FormGroup; + return this.argumentType.value; } get refEntityKeyFormGroup(): FormGroup { @@ -130,14 +157,31 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI } ngOnInit(): void { + this.updatedFormValidators(); + this.updatedArgumentType(); this.argumentFormGroup.patchValue(this.argument, {emitEvent: false}); this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId); - this.updateEntityFilter(this.argument.refEntityId?.entityType, true); + this.updateEntityFilter(this.entityType, true); + this.updatedRefEntityIdState(this.entityType, false); this.toggleByEntityKeyType(this.argument.refEntityKey?.type); this.setInitialEntityKeyType(); + this.setInitialEntityType(); + if (this.watchKeyChange) { + this.setWatchKeyChange(); + } + + if (this.defaultValueRequired) { + this.argumentFormGroup.get('defaultValue').addValidators(Validators.required); + this.argumentFormGroup.get('defaultValue').updateValueAndValidity({onlySelf: true}); + } this.argumentTypes = Object.values(ArgumentType) - .filter(type => type !== ArgumentType.Rolling || this.calculatedFieldType === CalculatedFieldType.SCRIPT); + .filter(type => type !== ArgumentType.Rolling || this.isScript); + + if (this.readonly) { + this.argumentType.disable({emitEvent: false}); + this.argumentFormGroup.disable({emitEvent: false}); + } } ngAfterViewInit(): void { @@ -147,12 +191,13 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI } saveArgument(): void { - const { refEntityId, ...restConfig } = this.argumentFormGroup.value; - const value = (refEntityId.entityType === ArgumentEntityType.Current ? restConfig : { refEntityId, ...restConfig }) as CalculatedFieldArgumentValue; - if (refEntityId.entityType === ArgumentEntityType.Tenant) { - refEntityId.id = this.tenantId; + const value = this.argumentFormGroup.value as CalculatedFieldArgumentValue; + if (this.entityType === ArgumentEntityType.Owner) { + value.refDynamicSourceConfiguration = {type: ArgumentEntityType.Owner}; + } else if (this.entityType === ArgumentEntityType.Tenant) { + value.refEntityId = new TenantId(this.tenantId) as any; } - if (refEntityId.entityType !== ArgumentEntityType.Current && refEntityId.entityType !== ArgumentEntityType.Tenant) { + if (this.entityType !== ArgumentEntityType.Current && this.entityType !== ArgumentEntityType.Tenant) { value.entityName = this.entityNameSubject.value; } if (value.defaultValue) { @@ -166,6 +211,23 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI this.popover.hide(); } + private updatedFormValidators(): void { + this.argumentFormGroup.get('argumentName').addValidators( + [uniqueNameValidator(this.usedArgumentNames), forbiddenNamesValidator(this.forbiddenNames)]); + this.argumentFormGroup.get('argumentName').updateValueAndValidity({emitEvent: false}); + } + + private updatedArgumentType(): void { + let argumentType = ArgumentEntityType.Current; + if (this.argument.refDynamicSourceConfiguration?.type === ArgumentEntityType.Owner) { + this.enableAutocomplete = (this.entityId.entityType === EntityType.DEVICE_PROFILE || this.entityId.entityType === EntityType.ASSET_PROFILE); + argumentType = ArgumentEntityType.Owner; + } else if (this.argument.refEntityId?.entityType) { + argumentType = this.argument.refEntityId.entityType; + } + this.argumentType.setValue(argumentType, {emitEvent: false}); + } + private toggleByEntityKeyType(type: ArgumentType): void { const isAttribute = type === ArgumentType.Attribute; const isRolling = type === ArgumentType.Rolling; @@ -181,6 +243,12 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI case ArgumentEntityType.Current: entityFilter = this.currentEntityFilter; break; + case ArgumentEntityType.Owner: + entityFilter = { + type: AliasFilterType.singleEntity, + singleEntity: this.ownerId + }; + break; case ArgumentEntityType.Tenant: entityFilter = { type: AliasFilterType.singleEntity, @@ -197,7 +265,13 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI }; } if (!onInit) { - this.argumentFormGroup.get('refEntityKey').get('key').setValue(''); + this.argumentFormGroup.get('refEntityKey').get('key').setValue('', {emitEvents: !this.watchKeyChange}); + if (this.watchKeyChange && this.argumentFormGroup.get('argumentName').pristine) { + this.argumentFormGroup.get('argumentName').markAsUntouched({emitEvent: false}); + this.argumentFormGroup.get('argumentName').setValue('', {emitEvent: false}); + } + } else if (this.predefinedEntityFilter) { + entityFilter = this.predefinedEntityFilter; } this.entityFilter = entityFilter; this.cd.markForCheck(); @@ -205,41 +279,28 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI private observeEntityFilterChanges(): void { merge( - this.refEntityIdFormGroup.get('entityType').valueChanges, + this.argumentType.valueChanges, this.refEntityKeyFormGroup.get('type').valueChanges, - this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)), + this.argumentFormGroup.get('refEntityId').valueChanges.pipe(filter(Boolean)), this.refEntityKeyFormGroup.get('scope').valueChanges, ) .pipe(debounceTime(50), takeUntilDestroyed()) .subscribe(() => this.updateEntityFilter(this.entityType)); } - private observeEntityTypeChanges(): void { - this.refEntityIdFormGroup.get('entityType').valueChanges + private observeArgumentTypeChanges(): void { + this.argumentType.valueChanges .pipe(distinctUntilChanged(), takeUntilDestroyed()) .subscribe(type => { - this.argumentFormGroup.get('refEntityId').get('id').setValue(''); - const isEntityWithId = type !== ArgumentEntityType.Tenant && type !== ArgumentEntityType.Current; - this.argumentFormGroup.get('refEntityId') - .get('id')[isEntityWithId ? 'enable' : 'disable'](); - if (!isEntityWithId) { - this.entityNameSubject.next(null); - } + this.argumentFormGroup.get('refEntityId').setValue(null); + this.enableAutocomplete = (this.entityId.entityType === EntityType.DEVICE_PROFILE || this.entityId.entityType === EntityType.ASSET_PROFILE) && type === ArgumentEntityType.Owner; + this.updatedRefEntityIdState(type); if (!this.enableAttributeScopeSelection) { this.refEntityKeyFormGroup.get('scope').setValue(AttributeScope.SERVER_SCOPE); } }); } - private uniqNameRequired(): ValidatorFn { - return (control: FormControl) => { - const newName = control.value.trim().toLowerCase(); - const isDuplicate = this.usedArgumentNames?.some(name => name.toLowerCase() === newName); - - return isDuplicate ? { duplicateName: true } : null; - }; - } - private observeEntityKeyChanges(): void { this.argumentFormGroup.get('refEntityKey').get('type').valueChanges .pipe(takeUntilDestroyed()) @@ -247,29 +308,36 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI } private setInitialEntityKeyType(): void { - if (this.calculatedFieldType === CalculatedFieldType.SIMPLE && this.argument.refEntityKey?.type === ArgumentType.Rolling) { + if (!this.isScript && this.argument.refEntityKey?.type === ArgumentType.Rolling) { const typeControl = this.argumentFormGroup.get('refEntityKey').get('type'); typeControl.setValue(null); typeControl.markAsTouched(); } } - private forbiddenArgumentNameValidator(): ValidatorFn { - return (control: FormControl) => { - const trimmedValue = control.value.trim().toLowerCase(); - const forbiddenArgumentNames = ['ctx', 'e', 'pi']; - return forbiddenArgumentNames.includes(trimmedValue) ? { forbiddenName: true } : null; - }; + private setInitialEntityType() { + if (!this.argumentEntityTypes.includes(this.entityType)) { + this.argumentType.setValue(null); + this.argumentType.markAsTouched(); + } } - private observeUpdatePosition(): void { - merge( - this.refEntityIdFormGroup.get('entityType').valueChanges, - this.refEntityKeyFormGroup.get('type').valueChanges, - this.argumentFormGroup.get('timeWindow').valueChanges, - this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)), - ) - .pipe(delay(50), takeUntilDestroyed()) - .subscribe(() => this.popover.updatePosition()); + private setWatchKeyChange(): void { + this.refEntityKeyFormGroup.get('key').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((key) => { + if (this.argumentFormGroup.get('argumentName').pristine) { + this.argumentFormGroup.get('argumentName').setValue(key); + this.argumentFormGroup.get('argumentName').markAsTouched({emitEvent: false}); + } + }); + } + + private updatedRefEntityIdState(type: ArgumentEntityType, emitEvent = true): void { + const isEntityWithId = !!type && ![ArgumentEntityType.Tenant, ArgumentEntityType.Current, ArgumentEntityType.Owner].includes(type); + this.argumentFormGroup.get('refEntityId')[isEntityWithId ? 'enable' : 'disable']({emitEvent}); + if (!isEntityWithId) { + this.entityNameSubject.next(null); + } } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html similarity index 68% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html index 4086b3e7b0..fc3c6ac7b3 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html @@ -1,6 +1,6 @@ -
-
+
+
-
{{ 'common.name' | translate }}
+
{{ argumentNameColumn | translate }}
@@ -29,7 +31,7 @@ @@ -37,13 +39,15 @@ - + {{ 'entity.entity-type' | translate }}
@if (argument.refEntityId?.entityType === ArgumentEntityType.Tenant) { {{ 'calculated-fields.argument-current-tenant' | translate }} + } @else if (argument.refDynamicSourceConfiguration?.type === ArgumentEntityType.Owner) { + {{ 'calculated-fields.argument-owner' | translate }} } @else if (argument.refEntityId?.id) { {{ entityTypeTranslations.get(argument.refEntityId.entityType).type | translate }} } @else { @@ -59,7 +63,7 @@
@if (argument.refEntityId?.id && argument.refEntityId?.entityType !== ArgumentEntityType.Tenant) { - {{ entityNameMap.get(argument.refEntityId.id) ?? '' }} @@ -88,25 +92,37 @@ -
- +
+ @if (!isEditValue) { + + } @else { + + }
- - + +
-
- {{ 'calculated-fields.no-arguments' | translate }} -
- @if (errorText) { + @if (errorText || (dataSource.isEmpty() | async)) { }
+
+ {{ 'calculated-fields.no-arguments' | translate }} +
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.scss similarity index 87% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.scss index 430958d0f4..7a2b96f497 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,6 +41,10 @@ } } + .no-data-found { + font-size: 16px; + } + .max-args-warning { .mat-icon { color: #FAA405; @@ -51,6 +55,9 @@ --mat-badge-legacy-small-size-container-size: 8px; --mat-badge-small-size-container-overlap-offset: -5px; --mat-badge-small-size-text-size: 0; + width: 100%; + display: flex; + justify-content: end; } } @@ -62,7 +69,7 @@ } .arguments-table { - .mat-mdc-header-row.mat-row-select .mat-mdc-header-cell.entity-type-header { + .mat-mdc-header-row.mat-row-select .mat-mdc-header-cell:nth-child(2) { padding: 0 28px 0 0; } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts similarity index 80% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts index 28a3f09126..bd8292d113 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ import { AfterViewInit, + booleanAttribute, ChangeDetectorRef, Component, DestroyRef, @@ -43,17 +44,19 @@ import { CalculatedFieldArgumentValue, CalculatedFieldType, } from '@shared/models/calculated-field.models'; -import { CalculatedFieldArgumentPanelComponent } from '@home/components/calculated-fields/components/public-api'; +import { + CalculatedFieldArgumentPanelComponent +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component'; 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 { getEntityDetailsPageURL, isDefined, 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 { MatSort, SortDirection } from '@angular/material/sort'; import { getCurrentAuthState } from '@core/auth/auth.selectors'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -83,16 +86,26 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces @Input() entityId: EntityId; @Input() tenantId: string; @Input() entityName: string; - @Input() calculatedFieldType: CalculatedFieldType; + @Input() ownerId: EntityId; + @Input() isScript: boolean; + @Input({transform: booleanAttribute}) disable = false; + @Input({transform: booleanAttribute}) isEditValue = true; + @Input() watchKeyChange = false; @ViewChild(MatSort, { static: true }) sort: MatSort; errorText = ''; argumentsFormArray = this.fb.array([]); entityNameMap = new Map(); - sortOrder = { direction: 'asc', property: '' }; + sortOrder: { direction: SortDirection; property: string } = {direction: 'asc', property: ''}; dataSource = new CalculatedFieldArgumentDatasource(); + argumentNameColumn = 'common.name'; + argumentNameColumnCopy = 'calculated-fields.copy-argument-name'; + displayColumns = ['name', 'entityType', 'target', 'type', 'key', 'actions']; + + protected panelAdditionalCtx: Record + readonly entityTypeTranslations = entityTypeTranslations; readonly ArgumentTypeTranslations = ArgumentTypeTranslations; readonly ArgumentEntityType = ArgumentEntityType; @@ -105,14 +118,14 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces private propagateChange: (argumentsObj: 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 + protected fb: FormBuilder, + protected popoverService: TbPopoverService, + protected viewContainerRef: ViewContainerRef, + protected cd: ChangeDetectorRef, + protected renderer: Renderer2, + protected entityService: EntityService, + protected destroyRef: DestroyRef, + protected store: Store ) { this.argumentsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => { this.updateDataSource(value); @@ -121,9 +134,8 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces } ngOnChanges(changes: SimpleChanges): void { - if (changes.calculatedFieldType?.previousValue - && changes.calculatedFieldType.currentValue !== changes.calculatedFieldType.previousValue) { - this.argumentsFormArray.updateValueAndValidity(); + if (isDefined(changes.isScript?.previousValue) && changes.isScript.currentValue !== changes.isScript.previousValue) { + this.changeIsScriptMode(); } } @@ -139,11 +151,15 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces this.propagateChange = fn; } - registerOnTouched(_): void {} + registerOnTouched(_: any): void {} validate(): ValidationErrors | null { this.updateErrorText(); - return this.errorText ? { argumentsFormArray: false } : null; + return this.errorText || !this.argumentsFormArray.controls.length ? { argumentsFormArray: false } : null; + } + + setDisabledState(isDisabled: boolean): void { + this.disable = isDisabled; } onDelete($event: Event, argument: CalculatedFieldArgumentValue): void { @@ -153,7 +169,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces this.argumentsFormArray.markAsDirty(); } - manageArgument($event: Event, matButton: MatButton, argument = {} as CalculatedFieldArgumentValue): void { + manageArgument($event: Event, matButton: MatButton, argument = {} as CalculatedFieldArgumentValue, readonly: boolean = false): void { $event?.stopPropagation(); if (this.popoverComponent && !this.popoverComponent.tbHidden) { this.popoverComponent.hide(); @@ -168,19 +184,22 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces index, argument, entityId: this.entityId, - calculatedFieldType: this.calculatedFieldType, + isScript: this.isScript, buttonTitle: isExists ? 'action.apply' : 'action.add', tenantId: this.tenantId, entityName: this.entityName, + ownerId: this.ownerId, + watchKeyChange: this.watchKeyChange, usedArgumentNames: this.argumentsFormArray.value.map(({ argumentName }) => argumentName).filter(name => name !== argument.argumentName), + readonly }; this.popoverComponent = this.popoverService.displayPopover({ trigger, renderer: this.renderer, componentType: CalculatedFieldArgumentPanelComponent, hostView: this.viewContainerRef, - preferredPlacement: isExists ? ['left', 'leftTop', 'leftBottom'] : ['topRight', 'right', 'rightTop'], - context: ctx, + preferredPlacement: isExists ? ['leftOnly', 'leftTopOnly', 'leftBottomOnly'] : ['rightOnly', 'rightTopOnly', 'rightBottomOnly'], + context: Object.assign(ctx, this.panelAdditionalCtx), isModal: true }); this.popoverComponent.tbComponentRef.instance.argumentsDataApplied.subscribe(({ entityName, ...value }) => { @@ -203,14 +222,11 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces this.dataSource.loadData(sortedValue); } - private updateErrorText(): void { - if (this.calculatedFieldType === CalculatedFieldType.SIMPLE - && this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) { + protected updateErrorText(): void { + if (!this.isScript && this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) { this.errorText = 'calculated-fields.hint.arguments-simple-with-rolling'; } else if (this.argumentsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) { this.errorText = 'calculated-fields.hint.arguments-entity-not-found'; - } else if (!this.argumentsFormArray.controls.length) { - this.errorText = 'calculated-fields.hint.arguments-empty'; } else { this.errorText = ''; } @@ -225,7 +241,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces } writeValue(argumentsObj: Record): void { - this.argumentsFormArray.clear(); + this.argumentsFormArray.clear({emitEvent: false}); this.populateArgumentsFormArray(argumentsObj); this.updateEntityNameMap(this.argumentsFormArray.value); } @@ -234,6 +250,14 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces return getEntityDetailsPageURL(id, type); } + protected changeIsScriptMode(): void { + this.argumentsFormArray.updateValueAndValidity({emitEvent: !this.disable}); + } + + protected isEditButtonShowBadge(argument: CalculatedFieldArgumentValue): boolean { + return !(argument.refEntityKey.type === ArgumentType.Rolling && !this.isScript) && argument.refEntityId?.id !== NULL_UUID + } + private populateArgumentsFormArray(argumentsObj: Record): void { Object.keys(argumentsObj).forEach(key => { const value: CalculatedFieldArgumentValue = { @@ -242,7 +266,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces }; this.argumentsFormArray.push(this.fb.control(value), { emitEvent: false }); }); - this.argumentsFormArray.updateValueAndValidity(); + this.updateDataSource(this.argumentsFormArray.value); } private updateEntityNameMap(values: CalculatedFieldArgumentValue[]): void { @@ -255,7 +279,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces return acc; }, {} as Record); const tasks = Object.entries(entitiesByType).map(([entityType, ids]) => - this.entityService.getEntities(entityType as EntityType, ids) + this.entityService.getEntities(entityType as EntityType, ids, {ignoreLoading: true}) ); if (!tasks.length) { return; diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts new file mode 100644 index 0000000000..2b392564f9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts @@ -0,0 +1,55 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + CalculatedFieldArgumentPanelComponent +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component'; +import { + CalculatedFieldArgumentsTableComponent +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component'; +import { + PropagateArgumentsTableComponent +} from '@home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component'; +import { + RelatedAggregationArgumentsTableComponent +} from '@home/components/calculated-fields/components/calculated-field-arguments/related-aggregation-arguments-table.component'; +import { + EntityAggregationArgumentsTableComponent +} from '@home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + ], + declarations: [ + CalculatedFieldArgumentPanelComponent, + CalculatedFieldArgumentsTableComponent, + PropagateArgumentsTableComponent, + RelatedAggregationArgumentsTableComponent, + EntityAggregationArgumentsTableComponent, + ], + exports: [ + CalculatedFieldArgumentsTableComponent, + PropagateArgumentsTableComponent, + RelatedAggregationArgumentsTableComponent, + EntityAggregationArgumentsTableComponent, + ] +}) +export class CalculatedFieldArgumentsTableModule {} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component.ts new file mode 100644 index 0000000000..b371653b18 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component.ts @@ -0,0 +1,72 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, forwardRef, Renderer2, ViewContainerRef, } from '@angular/core'; +import { FormBuilder, NG_VALIDATORS, NG_VALUE_ACCESSOR, } from '@angular/forms'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { EntityService } from '@core/http/entity.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + CalculatedFieldArgumentsTableComponent +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component'; +import { ArgumentEntityType } from '@shared/models/calculated-field.models'; + +@Component({ + selector: 'tb-entity-aggregation-arguments-table', + templateUrl: './calculated-field-arguments-table.component.html', + styleUrls: [`calculated-field-arguments-table.component.scss`], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityAggregationArgumentsTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => EntityAggregationArgumentsTableComponent), + multi: true + } + ], +}) +export class EntityAggregationArgumentsTableComponent extends CalculatedFieldArgumentsTableComponent { + + constructor( + protected fb: FormBuilder, + protected popoverService: TbPopoverService, + protected viewContainerRef: ViewContainerRef, + protected cd: ChangeDetectorRef, + protected renderer: Renderer2, + protected entityService: EntityService, + protected destroyRef: DestroyRef, + protected store: Store + ) { + super(fb, popoverService, viewContainerRef, cd, renderer, entityService, destroyRef, store); + + this.argumentNameColumn = 'calculated-fields.argument-name'; + this.displayColumns = ['name', 'type', 'key', 'actions']; + this.panelAdditionalCtx = { + hiddenEntityTypes: true, + argumentEntityTypes: [ArgumentEntityType.Current], + hint: 'calculated-fields.entity-aggregation.argument-setting-hint', + hiddenDefaultValue: true, + hiddenEntityKeyTypes: true, + watchKeyChange: true, + }; + + this.isScript = false; + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts new file mode 100644 index 0000000000..b84ea2ca9f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts @@ -0,0 +1,132 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, + forwardRef, + OnInit, + Renderer2, + ViewContainerRef, +} from '@angular/core'; +import { FormBuilder, NG_VALIDATORS, NG_VALUE_ACCESSOR, } from '@angular/forms'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { EntityService } from '@core/http/entity.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + CalculatedFieldArgumentsTableComponent +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component'; +import { + ArgumentEntityType, + ArgumentType, + CalculatedFieldArgumentValue, + FORBIDDEN_NAMES +} from '@shared/models/calculated-field.models'; +import { isDefined, isUndefinedOrNull } from '@core/utils'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; + +@Component({ + selector: 'tb-propagate-arguments-table', + templateUrl: './calculated-field-arguments-table.component.html', + styleUrls: [`calculated-field-arguments-table.component.scss`], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PropagateArgumentsTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => PropagateArgumentsTableComponent), + multi: true + } + ], +}) +export class PropagateArgumentsTableComponent extends CalculatedFieldArgumentsTableComponent implements OnInit { + + constructor( + protected fb: FormBuilder, + protected popoverService: TbPopoverService, + protected viewContainerRef: ViewContainerRef, + protected cd: ChangeDetectorRef, + protected renderer: Renderer2, + protected entityService: EntityService, + protected destroyRef: DestroyRef, + protected store: Store + ) { + super(fb, popoverService, viewContainerRef, cd, renderer, entityService, destroyRef, store) + } + + ngOnInit() { + this.updatedValue(); + } + + protected changeIsScriptMode(): void { + this.updatedValue(); + super.changeIsScriptMode(); + } + + private updatedValue() { + if (this.isScript) { + this.argumentNameColumn = 'common.name'; + this.argumentNameColumnCopy = 'calculated-fields.copy-argument-name'; + this.displayColumns = ['name', 'entityType', 'target', 'type', 'key', 'actions']; + this.panelAdditionalCtx = null; + } else { + this.argumentNameColumn = 'calculated-fields.output-key'; + this.argumentNameColumnCopy = 'calculated-fields.copy-output-key'; + this.displayColumns = ['name', 'type', 'key', 'actions']; + this.panelAdditionalCtx = { + argumentEntityTypes: [ArgumentEntityType.Current], + argumentNameContext: { + label: 'calculated-fields.output-key', + required: 'calculated-fields.hint.output-key-required', + duplicate: 'calculated-fields.hint.output-key-duplicate', + pattern: 'calculated-fields.hint.output-key-pattern', + maxlength: 'calculated-fields.hint.output-key-max-length', + forbidden: 'calculated-fields.hint.output-key-forbidden' + }, + watchKeyChange: true, + forbiddenNames: [...FORBIDDEN_NAMES, 'propagationCtx'], + }; + } + } + + protected isEditButtonShowBadge(argument: CalculatedFieldArgumentValue): boolean { + if (!this.isScript && (isDefined(argument?.refEntityId) || isDefined(argument?.refDynamicSourceConfiguration))) { + return false; + } + return super.isEditButtonShowBadge(argument); + } + + protected updateErrorText(): void { + if (!this.isScript && this.argumentsFormArray.controls.some(control => isDefined(control.value?.refEntityId) || isDefined(control.value.refDynamicSourceConfiguration))) { + this.errorText = 'calculated-fields.hint.arguments-propagate-argument-entity-type'; + } else if (!this.isScript && this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) { + this.errorText = 'calculated-fields.hint.arguments-propagate-arguments-with-rolling'; + } else if (this.argumentsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) { + this.errorText = 'calculated-fields.hint.arguments-entity-not-found'; + } else if (!this.argumentsFormArray.controls.length) { + this.errorText = 'calculated-fields.hint.arguments-empty'; + } else if (this.isScript && !this.argumentsFormArray.controls.some(control => isUndefinedOrNull(control.value?.refEntityId) && isUndefinedOrNull(control.value.refDynamicSourceConfiguration))) { + this.errorText = 'calculated-fields.hint.arguments-propagate-argument-must-current-entity'; + } else { + this.errorText = ''; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/related-aggregation-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/related-aggregation-arguments-table.component.ts new file mode 100644 index 0000000000..0a50a82729 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/related-aggregation-arguments-table.component.ts @@ -0,0 +1,96 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, + forwardRef, + Input, + Renderer2, + ViewContainerRef, +} from '@angular/core'; +import { FormBuilder, NG_VALIDATORS, NG_VALUE_ACCESSOR, } from '@angular/forms'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { EntityService } from '@core/http/entity.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + CalculatedFieldArgumentsTableComponent +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component'; +import { ArgumentEntityType, RelationPathLevel } from '@shared/models/calculated-field.models'; +import { AliasFilterType } from '@shared/models/alias.models'; +import { EntityType } from '@shared/models/entity-type.models'; + +@Component({ + selector: 'tb-related-aggregation-arguments-table', + templateUrl: './calculated-field-arguments-table.component.html', + styleUrls: [`calculated-field-arguments-table.component.scss`], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RelatedAggregationArgumentsTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => RelatedAggregationArgumentsTableComponent), + multi: true + } + ], +}) +export class RelatedAggregationArgumentsTableComponent extends CalculatedFieldArgumentsTableComponent { + + @Input({required: true}) + set relation(value: RelationPathLevel) { + this.panelAdditionalCtx.predefinedEntityFilter = { + type: AliasFilterType.relationsQuery, + rootStateEntity: false, + rootEntity: this.entityId, + direction: value.direction, + filters: [{ + relationType: value.relationType, + entityTypes: [EntityType.DEVICE, EntityType.ASSET, EntityType.CUSTOMER, EntityType.TENANT] + }], + maxLevel: 1, + }; + } + + constructor( + protected fb: FormBuilder, + protected popoverService: TbPopoverService, + protected viewContainerRef: ViewContainerRef, + protected cd: ChangeDetectorRef, + protected renderer: Renderer2, + protected entityService: EntityService, + protected destroyRef: DestroyRef, + protected store: Store + ) { + super(fb, popoverService, viewContainerRef, cd, renderer, entityService, destroyRef, store); + + this.argumentNameColumn = 'calculated-fields.argument-name'; + this.displayColumns = ['name', 'type', 'key', 'actions']; + this.panelAdditionalCtx = { + hiddenEntityTypes: true, + defaultValueRequired: true, + argumentEntityTypes: [ArgumentEntityType.Current], + hint: 'calculated-fields.hint.setting-arguments-aggregation', + watchKeyChange: true, + }; + + this.isScript = false; + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/common/calculated-field-panel.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/common/calculated-field-panel.scss new file mode 100644 index 0000000000..f3218dbab7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/common/calculated-field-panel.scss @@ -0,0 +1,61 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../../scss/constants'; + +:host { + .tb-config-panel { + width: 520px; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-lt-md} { + max-width: fit-content; + } + @media #{$mat-xs} { + width: 90vw; + } + + .tb-config-panel-title { + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + font-weight: 500; + font-size: 16px; + } + + .tb-config-panel-content { + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + + .fixed-title-width { + @media #{$mat-xs} { + min-width: 120px; + } + } + } + + .tb-config-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.ts deleted file mode 100644 index 478447d1d9..0000000000 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.ts +++ /dev/null @@ -1,70 +0,0 @@ -/// -/// Copyright © 2016-2025 The Thingsboard Authors -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -/// See the License for the specific language governing permissions and -/// limitations under the License. -/// - -import { AfterViewInit, Component, Inject, ViewChild } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; -import { Router } from '@angular/router'; -import { DialogComponent } from '@shared/components/dialog.component'; -import { CalculatedFieldEventBody, DebugEventType, Event, EventType } from '@shared/models/event.models'; -import { EventTableComponent } from '@home/components/event/event-table.component'; -import { - CalculatedField, - CalculatedFieldTestScriptFn, - CalculatedFieldType -} from '@shared/models/calculated-field.models'; - -export interface CalculatedFieldDebugDialogData { - tenantId: string; - value: CalculatedField; - getTestScriptDialogFn: CalculatedFieldTestScriptFn; -} - -@Component({ - selector: 'tb-calculated-field-debug-dialog', - styleUrls: ['calculated-field-debug-dialog.component.scss'], - templateUrl: './calculated-field-debug-dialog.component.html', -}) -export class CalculatedFieldDebugDialogComponent extends DialogComponent implements AfterViewInit { - - @ViewChild(EventTableComponent, {static: true}) eventsTable: EventTableComponent; - - readonly DebugEventType = DebugEventType; - readonly debugEventTypes = DebugEventType; - readonly EventType = EventType; - - constructor(protected store: Store, - protected router: Router, - @Inject(MAT_DIALOG_DATA) public data: CalculatedFieldDebugDialogData, - protected dialogRef: MatDialogRef) { - super(store, router, dialogRef); - } - - ngAfterViewInit(): void { - this.eventsTable.entitiesTable.cellActionDescriptors[0].isEnabled = (event => this.data.value.type === CalculatedFieldType.SCRIPT && !!(event as Event).body.arguments); - this.eventsTable.entitiesTable.updateData(); - } - - cancel(): void { - this.dialogRef.close(null); - } - - onDebugEventSelected(event: CalculatedFieldEventBody): void { - this.data.getTestScriptDialogFn(this.data.value, JSON.parse(event.arguments)) - .subscribe(expression => this.dialogRef.close(expression)); - } -} 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..b463930c09 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 @@ -1,6 +1,6 @@ -
+

{{ 'entity.type-calculated-field' | translate}}

- -
- -
-
-
-
-
{{ 'calculated-fields.output' | translate }}
-
- - {{ 'calculated-fields.output-type' | translate }} - - @for (type of outputTypes; track type) { - {{ OutputTypeTranslations.get(type) | translate}} - } - - - @if (outputFormGroup.get('type').value === OutputType.Attribute - && (data.entityId.entityType === EntityType.DEVICE || data.entityId.entityType === EntityType.DEVICE_PROFILE)) { - - {{ 'calculated-fields.attribute-scope' | translate }} - - - {{ 'calculated-fields.server-attributes' | translate }} - - - {{ 'calculated-fields.shared-attributes' | translate }} - - - - } -
- @if (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) { -
- - - {{ (outputFormGroup.get('type').value === OutputType.Timeseries - ? 'calculated-fields.timeseries-key' - : 'calculated-fields.attribute-key') - | translate }} - - - @if (outputFormGroup.get('name').errors && outputFormGroup.get('name').touched) { - - @if (outputFormGroup.get('name').hasError('required')) { - {{ 'common.hint.key-required' | translate }} - } @else if (outputFormGroup.get('name').hasError('pattern')) { - {{ 'common.hint.key-pattern' | translate }} - } @else if (outputFormGroup.get('name').hasError('maxlength')) { - {{ 'common.hint.key-max-length' | translate }} - } - - } - - - {{ 'calculated-fields.decimals-by-default' | translate }} - - @if (outputFormGroup.get('decimalsByDefault').errors && outputFormGroup.get('decimalsByDefault').touched) { - {{ 'calculated-fields.hint.decimals-range' | translate }} - } - -
-
- -
- calculated-fields.use-latest-timestamp -
-
-
- } -
- + @switch (fieldFormGroup.get('type').value) { + @case (CalculatedFieldType.GEOFENCING) { + + } + @case (CalculatedFieldType.PROPAGATION) { + + } + @case (CalculatedFieldType.RELATED_ENTITIES_AGGREGATION) { + + } + @case (CalculatedFieldType.ENTITY_AGGREGATION) { + + } + @default { + + } + }
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss index e192e3ccc0..fb02408d02 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,10 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - .calculated-field-dialog-container { width: 869px; max-width: 100%; + display: grid; + grid-template-rows: min-content minmax(auto, 1fr) min-content; + --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + --mdc-outlined-text-field-container-shape: 6px; + --mat-form-field-trailing-icon-color: rgba(0, 0, 0, 0.56); } .tbel-script-lang-chip { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts index 975744b2c6..6e8b7fb93f 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 @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -18,31 +18,30 @@ import { Component, DestroyRef, Inject, ViewEncapsulation } from '@angular/core' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@shared/components/dialog.component'; import { CalculatedField, CalculatedFieldConfiguration, - calculatedFieldDefaultScript, + calculatedFieldsEntityTypeList, CalculatedFieldTestScriptFn, CalculatedFieldType, - CalculatedFieldTypeTranslations, - getCalculatedFieldArgumentsEditorCompleter, - getCalculatedFieldArgumentsHighlights, - OutputType, - OutputTypeTranslations + calculatedFieldTypes, + CalculatedFieldTypeTranslations } from '@shared/models/calculated-field.models'; -import { digitsRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; -import { AttributeScope } 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'; -import { ScriptLanguage } from '@shared/models/rule-node.models'; import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; import { Observable } from 'rxjs'; import { EntityId } from '@shared/models/id/entity-id'; import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; +import { deepTrim } from '@core/utils'; +import { BaseData } from '@shared/models/base-data'; +import { CalculatedFieldFormService } from '@core/services/calculated-field-form.service'; +import { FormGroup } from '@angular/forms'; +import { AssetInfo } from '@shared/models/asset.models'; +import { DeviceInfo } from '@shared/models/device.models'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; export interface CalculatedFieldDialogData { value?: CalculatedField; @@ -50,9 +49,11 @@ export interface CalculatedFieldDialogData { entityId: EntityId; tenantId: string; entityName?: string; + ownerId: EntityId; additionalDebugActionConfig: AdditionalDebugActionConfig<(calculatedField: CalculatedField) => void>; getTestScriptDialogFn: CalculatedFieldTestScriptFn; isDirty?: boolean; + disabledSelectType?: boolean; } @Component({ @@ -63,55 +64,23 @@ export interface CalculatedFieldDialogData { }) export class CalculatedFieldDialogComponent extends DialogComponent { - fieldFormGroup = this.fb.group({ - name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], - type: [CalculatedFieldType.SIMPLE], - debugSettings: [], - configuration: this.fb.group({ - arguments: this.fb.control({}), - expressionSIMPLE: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], - expressionSCRIPT: [calculatedFieldDefaultScript], - output: this.fb.group({ - name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], - scope: [{ value: AttributeScope.SERVER_SCOPE, disabled: true }], - type: [OutputType.Timeseries], - decimalsByDefault: [null as number, [Validators.min(0), Validators.max(15), Validators.pattern(digitsRegex)]], - }), - useLatestTs: [false] - }), - }); - - functionArgs$ = this.configFormGroup.get('arguments').valueChanges - .pipe( - startWith(this.data.value?.configuration?.arguments ?? {}), - map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)]) - ); - - argumentsEditorCompleter$ = this.configFormGroup.get('arguments').valueChanges - .pipe( - startWith(this.data.value?.configuration?.arguments ?? {}), - map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj)) - ); - - argumentsHighlightRules$ = this.configFormGroup.get('arguments').valueChanges - .pipe( - startWith(this.data.value?.configuration?.arguments ?? {}), - map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj)) - ); + fieldFormGroup: FormGroup; additionalDebugActionConfig = this.data.value?.id ? { ...this.data.additionalDebugActionConfig, action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }), } : null; - readonly OutputTypeTranslations = OutputTypeTranslations; - readonly OutputType = OutputType; - readonly AttributeScope = AttributeScope; + entityName = this.data.entityName; + ownerId = this.data.ownerId; + + disabledConfiguration = false; + isLoading = false; + readonly EntityType = EntityType; + readonly calculatedFieldsEntityTypeList = calculatedFieldsEntityTypeList; readonly CalculatedFieldType = CalculatedFieldType; - readonly ScriptLanguage = ScriptLanguage; - readonly fieldTypes = Object.values(CalculatedFieldType) as CalculatedFieldType[]; - readonly outputTypes = Object.values(OutputType) as OutputType[]; + readonly fieldTypes = calculatedFieldTypes; readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations; constructor(protected store: Store, @@ -120,34 +89,37 @@ export class CalculatedFieldDialogComponent extends DialogComponent, private calculatedFieldsService: CalculatedFieldsService, private destroyRef: DestroyRef, - private fb: FormBuilder) { + private cfFormService: CalculatedFieldFormService) { super(store, router, dialogRef); - this.observeIsLoading(); + this.fieldFormGroup = this.cfFormService.buildForm(); + this.cfFormService.setupTypeChange(this.fieldFormGroup, this.destroyRef); this.applyDialogData(); - this.observeTypeChanges(); - } - get configFormGroup(): FormGroup { - return this.fieldFormGroup.get('configuration') as FormGroup; - } + if (this.data.isDirty) { + this.fieldFormGroup.markAsDirty(); + } + + if (!this.data.entityId) { + this.fieldFormGroup.get('entityId').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((entityId) => { + this.disabledConfiguration = !entityId; + if (this.disabledConfiguration) { + this.fieldFormGroup.get('configuration').disable({emitEvent: false}); + } else { + this.fieldFormGroup.get('configuration').enable({emitEvent: false}); + this.fieldFormGroup.get('configuration').updateValueAndValidity({emitEvent: false}); + } + }); + } - get outputFormGroup(): FormGroup { - return this.fieldFormGroup.get('configuration').get('output') as FormGroup; + if (this.data.disabledSelectType) { + this.fieldFormGroup.get('type').disable({emitEvent: false}); + } } get fromGroupValue(): CalculatedField { - const { configuration, type, name, ...rest } = this.fieldFormGroup.value; - const { expressionSIMPLE, expressionSCRIPT, output, ...restConfig } = configuration; - return { - configuration: { - ...restConfig, - type, expression: configuration['expression'+type].trim(), - output: { ...output, name: output.name?.trim() ?? '' } - }, - name: name.trim(), - type, - ...rest, - } as CalculatedField; + return deepTrim(this.fieldFormGroup.getRawValue() as CalculatedField); } cancel(): void { @@ -156,101 +128,51 @@ export class CalculatedFieldDialogComponent extends DialogComponent this.dialogRef.close(calculatedField)); - } - } - - onTestScript(): void { - const calculatedFieldId = this.data.value?.id?.id; - let testScriptDialogResult$: Observable; - - if (calculatedFieldId) { - testScriptDialogResult$ = this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId) - .pipe( - switchMap(event => { - const args = event?.arguments ? JSON.parse(event.arguments) : null; - return this.data.getTestScriptDialogFn(this.fromGroupValue, args, false); - }), - takeUntilDestroyed(this.destroyRef) - ) + .subscribe({ + next: calculatedField => this.dialogRef.close(calculatedField), + error: () => this.isLoading = false + }); } else { - testScriptDialogResult$ = this.data.getTestScriptDialogFn(this.fromGroupValue, null, false); + this.fieldFormGroup.get('name').markAsTouched(); } - - testScriptDialogResult$.subscribe(expression => { - this.configFormGroup.get('expressionSCRIPT').setValue(expression); - this.configFormGroup.get('expressionSCRIPT').markAsDirty(); - }); } - private applyDialogData(): void { - const { configuration = {}, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {}; - const { expression, ...restConfig } = configuration as CalculatedFieldConfiguration; - const updatedConfig = { ...restConfig , ['expression'+type]: expression }; - this.fieldFormGroup.patchValue({ configuration: updatedConfig, type, debugSettings, ...value }, {emitEvent: false}); + onTestScript(expression?: string): Observable { + return this.cfFormService.testScript( + this.data.value?.id?.id, + this.fromGroupValue, + this.data.getTestScriptDialogFn, + this.destroyRef, + expression + ); } - private observeTypeChanges(): void { - this.toggleKeyByCalculatedFieldType(this.fieldFormGroup.get('type').value); - this.toggleScopeByOutputType(this.outputFormGroup.get('type').value); - - this.outputFormGroup.get('type').valueChanges - .pipe(takeUntilDestroyed()) - .subscribe(type => this.toggleScopeByOutputType(type)); - this.fieldFormGroup.get('type').valueChanges - .pipe(takeUntilDestroyed()) - .subscribe(type => this.toggleKeyByCalculatedFieldType(type)); + changeEntity(entity: BaseData): void { + this.entityName = entity.name; + if (this.isAssignedToCustomer(entity as AssetInfo | DeviceInfo)) { + this.ownerId = (entity as AssetInfo | DeviceInfo).customerId; + } } - private toggleScopeByOutputType(type: OutputType): void { - if (type === OutputType.Attribute) { - this.outputFormGroup.get('scope').enable({emitEvent: false}); - } else { - this.outputFormGroup.get('scope').disable({emitEvent: false}); - } - if (this.fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) { - if (type === OutputType.Attribute) { - this.configFormGroup.get('useLatestTs').disable({emitEvent: false}); - } else { - this.configFormGroup.get('useLatestTs').enable({emitEvent: false}); - } - } else { - this.configFormGroup.get('useLatestTs').disable({emitEvent: false}); - } + get entityId(): EntityId { + return this.data.entityId || this.fieldFormGroup.get('entityId').value; } - private toggleKeyByCalculatedFieldType(type: CalculatedFieldType): void { - if (type === CalculatedFieldType.SIMPLE) { - this.outputFormGroup.get('name').enable({emitEvent: false}); - this.configFormGroup.get('expressionSIMPLE').enable({emitEvent: false}); - this.configFormGroup.get('expressionSCRIPT').disable({emitEvent: false}); - if (this.outputFormGroup.get('type').value === OutputType.Attribute) { - this.configFormGroup.get('useLatestTs').disable({emitEvent: false}); - } else { - this.configFormGroup.get('useLatestTs').enable({emitEvent: false}); - } - } else { - this.outputFormGroup.get('name').disable({emitEvent: false}); - this.configFormGroup.get('useLatestTs').disable({emitEvent: false}); - this.configFormGroup.get('expressionSIMPLE').disable({emitEvent: false}); - this.configFormGroup.get('expressionSCRIPT').enable({emitEvent: false}); + private applyDialogData(): void { + const { configuration = {} as CalculatedFieldConfiguration, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, entityId = this.data.entityId, ...value } = this.data.value ?? {}; + const preparedConfig = this.cfFormService.prepareConfig(configuration); + this.fieldFormGroup.patchValue({ configuration: preparedConfig, type, debugSettings, entityId, ...value }, {emitEvent: false}); + setTimeout(() => this.fieldFormGroup.get('type').updateValueAndValidity({onlySelf: true})); + if (!this.data.entityId) { + this.fieldFormGroup.get('configuration').disable({emitEvent: false}); + this.disabledConfiguration = true; } } - private observeIsLoading(): void { - this.isLoading$.pipe(takeUntilDestroyed()).subscribe(loading => { - if (loading) { - this.fieldFormGroup.disable({emitEvent: false}); - } else { - this.fieldFormGroup.enable({emitEvent: false}); - this.toggleScopeByOutputType(this.outputFormGroup.get('type').value); - this.toggleKeyByCalculatedFieldType(this.fieldFormGroup.get('type').value); - if (this.data.isDirty) { - this.fieldFormGroup.markAsDirty(); - } - } - }); + private isAssignedToCustomer(entity: AssetInfo | DeviceInfo): boolean { + return entity && entity.customerId && entity.customerId.id !== NULL_UUID; } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html new file mode 100644 index 0000000000..ab7987c340 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html @@ -0,0 +1,144 @@ + +
+
+
+ {{ 'calculated-fields.arguments' | translate }} +
+
+ {{ 'calculated-fields.entity-aggregation.argument-hint' | translate }} +
+ +
+
+
+ {{ 'calculated-fields.metrics.metrics' | translate }} +
+ +
+
+
+ {{ 'calculated-fields.entity-aggregation.aggregation-interval' | translate }} +
+ +
+ + calculated-fields.aggregate-interval-type + + + {{ AggIntervalTypeTranslations.get(type) | translate }} + + + + + +
+ @if (entityAggregationConfiguration.get('interval.type').value === AggIntervalType.CUSTOM) { + + + } +
+ +
+ {{ 'calculated-fields.entity-aggregation.apply-offset' | translate }} +
+
+ @if (entityAggregationConfiguration.get('interval.allowOffsetSec').value) { + + +
+ {{ hint }} +
+ } +
+
+
+ +
+ {{ 'calculated-fields.entity-aggregation.wait-delay' | translate }} +
+
+ @if (entityAggregationConfiguration.get('allowWatermark').value) { + + + + + } +
+
+ +
+ {{ 'calculated-fields.entity-aggregation.produce-intermediate-result' | translate }} +
+
+
+
+ +
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts new file mode 100644 index 0000000000..11c95352d3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts @@ -0,0 +1,461 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { booleanAttribute, Component, forwardRef, Input } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { EntityId } from '@shared/models/id/entity-id'; +import { + AggInterval, + AggIntervalType, + AggIntervalTypeTranslations, + CalculatedFieldEntityAggregationConfiguration, + CalculatedFieldOutput, + CalculatedFieldType, + defaultCalculatedFieldOutput, + notEmptyObjectValidator, +} from '@shared/models/calculated-field.models'; +import { filter, map } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AVG_MONTH, AVG_QUARTER, DAY, HOUR, MINUTE, SECOND, WEEK, YEAR } from '@shared/models/time/time.models'; +import { deepClone, isDefinedAndNotNull } from '@core/utils'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { merge, Observable } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; +import _moment from 'moment'; + +interface CalculatedFieldEntityAggregationConfigurationValue extends CalculatedFieldEntityAggregationConfiguration { + interval: AggInterval & {allowOffsetSec?: boolean}; + allowWatermark: boolean; +} + +enum TimeCategory { + SECONDS = 'SECONDS', + MINUTES = 'MINUTES', + HOURS = 'HOURS', + DAYS = 'DAYS' +} + +@Component({ + selector: 'tb-entity-aggregation-component', + templateUrl: './entity-aggregation-component.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityAggregationComponentComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => EntityAggregationComponentComponent), + multi: true + } + ], +}) +export class EntityAggregationComponentComponent implements ControlValueAccessor, Validator { + + @Input({required: true}) + entityId: EntityId; + + @Input({required: true}) + tenantId: string; + + @Input({required: true}) + entityName: string; + + @Input() testScript: (expression?: string) => Observable; + + @Input({transform: booleanAttribute}) isEditValue = true; + + readonly minAllowedAggregationIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedAggregationIntervalInSecForCF; + readonly intermediateAggregationIntervalInSecForCF = getCurrentAuthState(this.store).intermediateAggregationIntervalInSecForCF; + readonly DayInSec = DAY / SECOND; + + entityAggregationConfiguration = this.fb.group({ + arguments: this.fb.control({}, notEmptyObjectValidator()), + metrics: this.fb.control({}, notEmptyObjectValidator()), + interval: this.fb.group({ + type: [AggIntervalType.HOUR], + tz: ['', Validators.required], + durationSec: [this.minAllowedAggregationIntervalInSecForCF, Validators.required], + allowOffsetSec: [false], + offsetSec: [this.minAllowedAggregationIntervalInSecForCF > 60 ? MINUTE / SECOND : 1, Validators.required], + }), + allowWatermark: [false], + watermark: this.fb.group({ + duration: [HOUR/SECOND, Validators.required], + }), + produceIntermediateResult: [false], + output: this.fb.control(defaultCalculatedFieldOutput), + }); + + arguments$ = this.entityAggregationConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => Object.keys(argumentsObj)) + ); + + AggIntervalType = AggIntervalType; + AggIntervalTypes = Object.values(AggIntervalType) as AggIntervalType[]; + AggIntervalTypeTranslations = AggIntervalTypeTranslations; + + hint: string; + + private propagateChange: (config: CalculatedFieldEntityAggregationConfiguration) => void = () => { }; + + constructor(private fb: FormBuilder, + private store: Store, + private translate: TranslateService,) { + + this.entityAggregationConfiguration.get('interval.type').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((type: AggIntervalType) => { + this.checkAggIntervalType(type); + }); + + this.entityAggregationConfiguration.get('interval.allowOffsetSec').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((allow: boolean) => { + this.checkIntervalDuration(allow); + }); + + this.entityAggregationConfiguration.get('allowWatermark').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((allow: boolean) => { + this.checkWatermark(allow); + }); + + merge( + this.entityAggregationConfiguration.get('interval.type').valueChanges, + this.entityAggregationConfiguration.get('interval.durationSec').valueChanges, + this.entityAggregationConfiguration.get('interval.offsetSec').valueChanges, + this.entityAggregationConfiguration.get('interval.allowOffsetSec').valueChanges, + ).pipe( + filter(() => this.entityAggregationConfiguration.get('interval.allowOffsetSec').value), + takeUntilDestroyed() + ).subscribe(() => { + this.updatedOffsetHint(); + }); + + merge( + this.entityAggregationConfiguration.get('interval.type').valueChanges, + this.entityAggregationConfiguration.get('interval.durationSec').valueChanges + ).pipe( + takeUntilDestroyed() + ).subscribe(() => { + this.checkProduceIntermediate(); + }); + + this.entityAggregationConfiguration.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((value: CalculatedFieldEntityAggregationConfigurationValue) => { + this.updatedModel(deepClone(value)); + }); + } + + validate(): ValidationErrors | null { + return this.entityAggregationConfiguration.valid || this.entityAggregationConfiguration.disabled ? null : {invalidPropagateConfig: false}; + } + + writeValue(value: CalculatedFieldEntityAggregationConfiguration): void { + const data: CalculatedFieldEntityAggregationConfigurationValue = { + ...value, + allowWatermark: isDefinedAndNotNull(value.watermark), + interval: {...value.interval, allowOffsetSec: isDefinedAndNotNull(value?.interval?.offsetSec)} + } + this.entityAggregationConfiguration.patchValue(data, {emitEvent: false}); + if (this.entityAggregationConfiguration.enabled) { + this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value); + this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value); + this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value); + this.checkProduceIntermediate(); + } + this.updatedOffsetHint(); + setTimeout(() => { + this.entityAggregationConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); + }); + } + + registerOnChange(fn: (config: CalculatedFieldEntityAggregationConfiguration) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_: any): void { } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.entityAggregationConfiguration.disable({emitEvent: false}); + } else { + this.entityAggregationConfiguration.enable({emitEvent: false}); + this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value); + this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value); + this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value); + this.checkProduceIntermediate(); + } + } + + get maxOffsetTime(): number { + switch (this.entityAggregationConfiguration.get('interval.type').value as AggIntervalType) { + case AggIntervalType.HOUR: + return HOUR / SECOND - 1; + case AggIntervalType.DAY: + return DAY / SECOND - 1; + case AggIntervalType.WEEK: + case AggIntervalType.WEEK_SUN_SAT: + return 7 * DAY / SECOND - 1; + case AggIntervalType.MONTH: + return AVG_MONTH / SECOND; + case AggIntervalType.QUARTER: + return AVG_QUARTER / SECOND - 1; + case AggIntervalType.YEAR: + return YEAR / SECOND - 1; + case AggIntervalType.CUSTOM: + return this.entityAggregationConfiguration.get('interval.durationSec').value - 1; + } + } + + private updatedModel(value: CalculatedFieldEntityAggregationConfigurationValue): void { + value.type = CalculatedFieldType.ENTITY_AGGREGATION; + if (!value.interval.allowOffsetSec) { + delete value.interval.offsetSec; + } + delete value.interval.allowOffsetSec; + if (!value.allowWatermark) { + delete value.watermark; + } + delete value.allowWatermark; + this.propagateChange(value); + } + + private checkAggIntervalType(type: AggIntervalType) { + if (type === AggIntervalType.CUSTOM) { + this.entityAggregationConfiguration.get('interval.durationSec').enable({emitEvent: false}); + } else { + this.entityAggregationConfiguration.get('interval.durationSec').disable({emitEvent: false}); + } + } + + private checkIntervalDuration(allow: boolean) { + if (allow) { + this.entityAggregationConfiguration.get('interval.offsetSec').enable({emitEvent: false}); + } else { + this.entityAggregationConfiguration.get('interval.offsetSec').disable({emitEvent: false}); + this.hint = ''; + } + } + + private checkWatermark(allow: boolean) { + if (allow) { + this.entityAggregationConfiguration.get('watermark').enable({emitEvent: false}); + } else { + this.entityAggregationConfiguration.get('watermark').disable({emitEvent: false}); + } + } + + private checkProduceIntermediate() { + const intervalType = this.entityAggregationConfiguration.get('interval.type').value as AggIntervalType; + let durationSec = 0; + switch (intervalType) { + case AggIntervalType.CUSTOM: + durationSec = this.entityAggregationConfiguration.get('interval.durationSec').value; + break + case AggIntervalType.HOUR: + durationSec = HOUR / SECOND; + break + case AggIntervalType.DAY: + durationSec = DAY / SECOND; + break + case AggIntervalType.WEEK: + case AggIntervalType.WEEK_SUN_SAT: + durationSec = WEEK / SECOND; + break + case AggIntervalType.MONTH: + durationSec = AVG_MONTH / SECOND; + break + case AggIntervalType.QUARTER: + durationSec = AVG_QUARTER / SECOND; + break + case AggIntervalType.YEAR: + durationSec = YEAR / SECOND; + break + } + if (durationSec > this.intermediateAggregationIntervalInSecForCF) { + this.entityAggregationConfiguration.get('produceIntermediateResult').enable({emitEvent: false}); + } else { + this.entityAggregationConfiguration.get('produceIntermediateResult').disable({emitEvent: false}); + } + } + + private updatedOffsetHint(): void { + const offset = this.entityAggregationConfiguration.get('interval.offsetSec').value; + const intervalType = this.entityAggregationConfiguration.get('interval.type').value as AggIntervalType; + const durationSec = this.entityAggregationConfiguration.get('interval.durationSec').value; + const offsetCategory = this.getTimeCategory(offset); + const now = _moment.utc(); + let interval: string; + if (intervalType === AggIntervalType.CUSTOM) { + const formatString = this.getCustomFormatString(offsetCategory, this.getTimeCategory(durationSec)); + const intervals: string[] = []; + let allInterval = durationSec >= HOUR*6/SECOND && durationSec < DAY/SECOND; + now.startOf('year').add(offset, 'seconds'); + + let repeat = 2; + if (allInterval) { + repeat = Math.floor(DAY/SECOND/durationSec); + if (repeat > 4) { + repeat = 2; + allInterval = false; + } + } + + for (let i = 0; i < repeat; i++) { + const s1 = now.clone().add(i * durationSec, 'seconds').format(formatString); + const s2 = now.clone().add((i + 1) * durationSec, 'seconds').format(formatString); + intervals.push(`${s1} - ${s2}`); + } + interval = intervals.join('; '); + + if (allInterval) { + this.hint = this.translate.instant('calculated-fields.aggregate-period-hint-offset', {interval}); + } else { + interval += '…' + this.hint = this.translate.instant('calculated-fields.aggregate-period-hint-offset-and-so-on', {interval}); + } + } else { + interval = this.buildStandardIntervalString(now, intervalType, offset, offsetCategory); + this.hint = this.translate.instant('calculated-fields.aggregate-period-hint-offset-and-so-on', { interval }); + } + } + + private getTimeCategory(seconds: number): TimeCategory { + if (seconds % (DAY / SECOND) === 0) { + return TimeCategory.DAYS; + } + if (seconds % (HOUR / SECOND) === 0) { + return TimeCategory.HOURS; + } + if (seconds % (MINUTE / SECOND) === 0) { + return TimeCategory.MINUTES; + } + return TimeCategory.SECONDS; + } + + private getCustomFormatString(offsetCat: TimeCategory, durationCat: TimeCategory): string { + if (durationCat === TimeCategory.DAYS) { + if (offsetCat === TimeCategory.SECONDS) { + return '[Day] D, HH:mm:ss'; + } + if (offsetCat === TimeCategory.MINUTES || offsetCat === TimeCategory.HOURS) { + return '[Day] D, HH:mm'; + } + return '[Day] D'; + } else { + if (offsetCat === TimeCategory.SECONDS) { + return 'HH:mm:ss'; + } + return 'HH:mm'; + } + } + + private formatAdditiveInterval(now: _moment.Moment, addUnit: 'hour' | 'day' | 'month' | 'quarter', offsetCat: TimeCategory, + formats: { [key in TimeCategory]?: { s1: string, s2: string, s3: string } }): string { + const formatTs = formats[offsetCat] || formats[TimeCategory.SECONDS]; + + if (!formatTs) { + return ''; + } + + const s1 = now.format(formatTs.s1); + const s2 = now.clone().add(1, addUnit).format(formatTs.s2); + const s3 = now.clone().add(2, addUnit).format(formatTs.s3); + + return `${s1} - ${s2}; ${s2} - ${s3}…`; + } + + private formatNextInterval(now: _moment.Moment, offsetCat: TimeCategory, secFmt: string, minHourFmt: string, dayFmt: string): string { + let s1: string; + if (offsetCat === TimeCategory.SECONDS) { + s1 = now.format(secFmt); + } else if (offsetCat === TimeCategory.MINUTES || offsetCat === TimeCategory.HOURS) { + s1 = now.format(minHourFmt); + } else { + s1 = now.format(dayFmt); + } + + const s2 = `Next ${s1}`; + const s3 = `Following ${s1}`; + return `${s1} - ${s2}; ${s2} - ${s3}… `; + } + + private buildStandardIntervalString(now: _moment.Moment, type: AggIntervalType, offset: number, offsetCat: TimeCategory): string { + switch (type) { + case AggIntervalType.HOUR: + now.startOf('day').add(offset, 'seconds'); + return this.formatAdditiveInterval(now, 'hour', offsetCat, { + [TimeCategory.SECONDS]: { s1: 'HH:mm:ss', s2: 'HH:mm:ss', s3: 'HH:mm:ss' }, + [TimeCategory.MINUTES]: { s1: 'HH:mm:ss', s2: 'HH:mm', s3: 'HH:mm' } + }); + + case AggIntervalType.DAY: + now.startOf('month').add(offset, 'seconds'); + return this.formatAdditiveInterval(now, 'day', offsetCat, { + [TimeCategory.SECONDS]: { s1: '[Day] D, HH:mm:ss', s2: '[Day] D, HH:mm:ss', s3: '[Day] D, HH:mm:ss' }, + [TimeCategory.MINUTES]: { s1: '[Day] D, HH:mm:ss', s2: '[Day] D, HH:mm', s3: '[Day] D, HH:mm' }, + [TimeCategory.HOURS]: { s1: 'HH:mm:ss', s2: '[Day] D, HH:mm', s3: '[Day] D, HH:mm' } // Note: Original logic, s1 format is different + }); + + case AggIntervalType.WEEK: + now.isoWeekday(1).startOf('isoWeek').add(offset, 'seconds'); + return this.formatNextInterval(now, offsetCat, 'ddd, HH:mm:ss', 'ddd, HH:mm', 'ddd'); + + case AggIntervalType.WEEK_SUN_SAT: + now.startOf('week').add(offset, 'seconds'); + return this.formatNextInterval(now, offsetCat, 'ddd, HH:mm:ss', 'ddd, HH:mm', 'ddd'); + + case AggIntervalType.MONTH: + now.startOf('year').add(offset, 'seconds'); + return this.formatAdditiveInterval(now, 'month', offsetCat, { + [TimeCategory.SECONDS]: { s1: 'Do [of month], HH:mm:ss', s2: '[Next] Do, HH:mm:ss', s3: '[Following] Do, HH:mm:ss' }, + [TimeCategory.MINUTES]: { s1: 'Do [of month], HH:mm', s2: '[Next] Do, HH:mm', s3: '[Following] Do, HH:mm' }, + [TimeCategory.HOURS]: { s1: 'Do [of month], HH:mm', s2: '[Next] Do, HH:mm', s3: '[Following] Do, HH:mm' }, + [TimeCategory.DAYS]: { s1: 'Do [of month]', s2: '[Next] Do', s3: '[Following] Do' } + }); + + case AggIntervalType.QUARTER: + now.startOf('year').add(offset, 'seconds'); + return this.formatAdditiveInterval(now, 'quarter', offsetCat, { + [TimeCategory.SECONDS]: { s1: 'MMM Do, HH:mm:ss', s2: 'MMM Do, HH:mm:ss', s3: 'MMM Do, HH:mm:ss' }, + [TimeCategory.MINUTES]: { s1: 'MMM Do, HH:mm', s2: 'MMM Do, HH:mm', s3: 'MMM Do, HH:mm' }, + [TimeCategory.HOURS]: { s1: 'MMM Do, HH:mm', s2: 'MMM Do, HH:mm', s3: 'MMM Do, HH:mm' }, + [TimeCategory.DAYS]: { s1: 'MMM Do', s2: 'MMM Do', s3: 'MMM Do' } + }); + + case AggIntervalType.YEAR: + now.startOf('year').add(offset, 'seconds'); + return this.formatNextInterval(now, offsetCat, 'MMM Do, HH:mm:ss', 'MMM Do, HH:mm', 'MMM Do'); + + default: + return ''; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.module.ts new file mode 100644 index 0000000000..347d99ed15 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.module.ts @@ -0,0 +1,49 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + CalculatedFieldOutputModule +} from '@home/components/calculated-fields/components/output/calculated-field-output.module'; +import { + CalculatedFieldArgumentsTableModule +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module'; +import { + EntityAggregationComponentComponent +} from '@home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component'; +import { + CalculatedFieldMetricsTableModule +} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-table.module'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CalculatedFieldOutputModule, + CalculatedFieldArgumentsTableModule, + CalculatedFieldMetricsTableModule, + ], + declarations: [ + EntityAggregationComponentComponent, + ], + exports: [ + EntityAggregationComponentComponent, + ] +}) +export class EntityAggregationComponentModule { +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html new file mode 100644 index 0000000000..bb191e8f26 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html @@ -0,0 +1,274 @@ + +
+
{{ 'calculated-fields.geofencing-zone-groups-settings' | translate }}
+
+
+
{{ 'calculated-fields.name' | translate }}
+ + + @if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('required')) { + + warning + + } @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('duplicateName')) { + + warning + + } @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('pattern')) { + + warning + + } @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('maxlength')) { + + warning + + } @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('forbiddenName')) { + + warning + + } + +
+ +
+
{{ 'entity.entity-type' | translate }}
+ + + @for (type of argumentEntityTypes; track type) { + {{ ArgumentEntityTypeTranslations.get(type) | translate }} + } + + +
+ @if (ArgumentEntityTypeParamsMap.has(entityType)) { +
+
{{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}
+ +
+ } +
+ +
+ + + {{ 'calculated-fields.entity-zone-relationship' | translate }} + +
+
+
+
calculated-fields.level
+
calculated-fields.direction-level
+
calculated-fields.relation-type
+
+
+ @if (levelsFormArray()?.controls?.length) { +
+ @for (keyControl of levelsFormArray().controls; track trackByKey; ) { +
+
+ +
+
+
{{ $index + 1 }}
+ + + @for (direction of GeofencingDirectionList; track direction) { + {{ GeofencingDirectionLevelTranslations.get(direction) | translate }} + } + + + + +
+
+ +
+
+ } +
+ } @else { + {{ 'calculated-fields.no-level' | translate }} + } + @if (levelsFormArray().errors) { + + } +
+
+ @if (maxRelationLevelPerCfArgument && levelsFormArray().length >= maxRelationLevelPerCfArgument) { +
+ warning + {{ 'calculated-fields.max-allowed-levels-error' | translate }} +
+ } @else { + + } +
+
+
+
+ + @if (entityFilter.singleEntity?.id || entityType === ArgumentEntityType.RelationQuery || entityType === ArgumentEntityType.Current || entityType === ArgumentEntityType.Owner) { +
+
+ {{ 'calculated-fields.perimeter-attribute-key' | translate }} +
+ @if (entityType === ArgumentEntityType.RelationQuery) { + + + @if (geofencingFormGroup.get('perimeterKeyName').touched && geofencingFormGroup.get('perimeterKeyName').hasError('required')) { + + warning + + } @else if (geofencingFormGroup.get('perimeterKeyName').touched && geofencingFormGroup.get('perimeterKeyName').hasError('pattern')) { + + warning + + } + + } @else { + + } +
+ } +
+
{{ 'calculated-fields.report-strategy' | translate }}
+ + + @for (strategy of GeofencingReportStrategyList; track strategy) { + {{ GeofencingReportStrategyTranslations.get(strategy) | translate }} + } + + +
+
+
+ +
+ {{ 'calculated-fields.create-relation-with-matched-zones' | translate }} +
+
+
+
{{ 'calculated-fields.direction' | translate }}
+ + + @for (direction of GeofencingDirectionList; track direction) { + {{ GeofencingDirectionTranslations.get(direction) | translate }} + } + + +
+
+
{{ 'calculated-fields.relation-type' | translate }}
+ + +
+
+
+
+ + @if (!readonly) { + + } +
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.scss new file mode 100644 index 0000000000..6b3652865d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.scss @@ -0,0 +1,72 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../../scss/constants'; + +:host { + .level-text { + display: flex; + justify-content: center; + width: 25px; + color: rgba(0, 0, 0, 0.54); + } + + .tb-form-table { + .tb-form-row { + gap: 12px; + } + + .tb-form-table-body { + gap: unset; + } + + .tb-form-table-header { + padding: 0; + } + + .tb-form-table-header-cell { + &.tb-actions-header { + width: 40px; + min-width: 40px; + } + } + } + + .max-args-warning { + .mat-icon { + color: #FAA405; + } + } + + .limit-field-row { + @media screen and (max-width: 520px) { + display: flex; + flex-direction: column; + + .fixed-title-width { + align-self: flex-start; + padding-top: 8px; + } + } + } +} + +:host ::ng-deep { + tb-entity-autocomplete { + .mat-mdc-form-field-has-icon-suffix .mat-mdc-text-field-wrapper { + padding-right: 0 !important; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts new file mode 100644 index 0000000000..fc70058f8b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts @@ -0,0 +1,331 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { + AbstractControl, + FormBuilder, + FormControl, + FormGroup, + UntypedFormArray, + ValidatorFn, + Validators +} from '@angular/forms'; +import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; +import { + ArgumentEntityType, + ArgumentEntityTypeParamsMap, + ArgumentEntityTypeTranslations, + CalculatedFieldGeofencing, + CalculatedFieldGeofencingValue, + FORBIDDEN_NAMES, + forbiddenNamesValidator, + GeofencingDirectionLevelTranslations, + GeofencingDirectionTranslations, + GeofencingReportStrategy, + GeofencingReportStrategyTranslations, + getCalculatedFieldCurrentEntityFilter, + uniqueNameValidator +} from '@shared/models/calculated-field.models'; +import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'; +import { EntityType } from '@shared/models/entity-type.models'; +import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EntityFilter } from '@shared/models/query/query.models'; +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'; +import { CdkDragDrop } from "@angular/cdk/drag-drop"; + +@Component({ + selector: 'tb-calculated-field-geofencing-zone-groups-panel', + templateUrl: './calculated-field-geofencing-zone-groups-panel.component.html', + styleUrls: ['../common/calculated-field-panel.scss', './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() ownerId: EntityId; + @Input() usedNames: string[]; + @Input() readonly = false; + + @ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent; + + geofencingDataApplied = output(); + + readonly maxRelationLevelPerCfArgument = getCurrentAuthState(this.store).maxRelationLevelPerCfArgument; + + geofencingFormGroup = this.fb.group({ + name: ['', [Validators.required, forbiddenNamesValidator(FORBIDDEN_NAMES), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], + refEntityId: this.fb.group({ + entityType: [ArgumentEntityType.Current], + id: [''] + }), + refDynamicSourceConfiguration: this.fb.group({ + levels: this.fb.array([], [this.levelsRequired()]) + }), + perimeterKeyName: ['', [Validators.required, 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); + + enableAutocomplete = false; + + 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; + readonly GeofencingDirectionLevelTranslations = GeofencingDirectionLevelTranslations; + readonly AttributeScope = AttributeScope; + + private currentEntityFilter: EntityFilter; + + constructor( + private fb: FormBuilder, + private cd: ChangeDetectorRef, + private popover: TbPopoverComponent, + private store: Store + ) { + + this.observeEntityFilterChanges(); + this.observeEntityTypeChanges(); + 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.updatedFormValidators(); + this.geofencingFormGroup.patchValue(this.zone, {emitEvent: false}); + if (this.zone.refDynamicSourceConfiguration?.type) { + this.refEntityIdFormGroup.get('entityType').setValue(this.zone.refDynamicSourceConfiguration.type, {emitEvent: false}); + } + if (this.zone?.refDynamicSourceConfiguration?.levels?.length > 0) { + this.zone.refDynamicSourceConfiguration.levels.forEach(level => { + this.levelsFormArray().push(this.fb.group({ + direction: [level.direction], + relationType: [level.relationType, [Validators.required]] + })); + }) + } else { + this.addKey(); + } + this.enableAutocomplete = (this.entityId.entityType === EntityType.DEVICE_PROFILE || this.entityId.entityType === EntityType.ASSET_PROFILE) && + this.refEntityIdFormGroup.get('entityType').value === ArgumentEntityType.Owner; + 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); + + if (this.readonly) { + this.geofencingFormGroup.disable({emitEvent: false}); + } + } + + fetchOptions(searchText: string): Observable> { + const search = searchText ? searchText?.toLowerCase() : ''; + return of(['Contains', 'Manages']).pipe(map(name => name?.filter(option => option.toLowerCase().includes(search)))); + } + + private updatedFormValidators(): void { + this.geofencingFormGroup.get('name').addValidators(uniqueNameValidator(this.usedNames)); + this.geofencingFormGroup.get('name').updateValueAndValidity({emitEvent: false}); + } + + private observeCreateRelationZonesChanges(): void { + this.geofencingFormGroup.get('createRelationsWithMatchedZones').valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(value => this.validateDirectionAndRelationType(value)); + } + + 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.Owner: + delete value.refEntityId; + value.refDynamicSourceConfiguration = {type: ArgumentEntityType.Owner}; + 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.Owner: + entityFilter = { + type: AliasFilterType.singleEntity, + singleEntity: this.ownerId + }; + 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(debounceTime(50), takeUntilDestroyed()) + .subscribe(() => this.updateEntityFilter(this.entityType)); + } + + private observeEntityTypeChanges(): void { + this.refEntityIdFormGroup.get('entityType').valueChanges + .pipe(distinctUntilChanged(), takeUntilDestroyed()) + .subscribe(type => { + this.enableAutocomplete = (this.entityId.entityType === EntityType.DEVICE_PROFILE || this.entityId.entityType === EntityType.ASSET_PROFILE) && type === ArgumentEntityType.Owner; + this.geofencingFormGroup.get('refEntityId').get('id').setValue(null); + this.geofencingFormGroup.get('perimeterKeyName').reset(''); + const isEntityWithId = !!type && ![ArgumentEntityType.Tenant, ArgumentEntityType.Current, ArgumentEntityType.Owner, ArgumentEntityType.RelationQuery].includes(type); + this.geofencingFormGroup.get('refEntityId').get('id')[isEntityWithId ? 'enable' : 'disable'](); + if (!isEntityWithId) { + this.entityNameSubject.next(null); + } + this.validateRefDynamicSourceConfiguration(type); + }); + } + + private levelsRequired(): ValidatorFn { + return (control: FormControl) => { + return control.value.length ? null : { levelsRequired: true }; + }; + } + + levelsFormArray(): UntypedFormArray { + return this.refDynamicSourceFormGroup.get('levels') as UntypedFormArray; + } + + trackByKey(_index: number, keyControl: AbstractControl): any { + return keyControl; + } + + removeKey(index: number) { + this.levelsFormArray().removeAt(index); + this.levelsFormArray().markAsDirty(); + } + + addKey() { + this.levelsFormArray().push(this.fb.group({ + direction: [EntitySearchDirection.TO], + relationType: ['', [Validators.required]] + })); + } + + keyDrop(event: CdkDragDrop) { + const keysArray = this.levelsFormArray(); + const key = keysArray.at(event.previousIndex); + keysArray.removeAt(event.previousIndex); + keysArray.insert(event.currentIndex, key); + } + + get dragEnabled(): boolean { + return this.levelsFormArray().controls.length > 1 && !this.readonly; + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.html new file mode 100644 index 0000000000..92fded8a99 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.html @@ -0,0 +1,163 @@ + +
+
+ + + +
{{ '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.Owner) { + {{ 'calculated-fields.argument-owner' | 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 }}
+
+
+ + + + +
+ @if (!isEditValue) { + + } @else { + + } + +
+
+
+ + +
+ @if (errorText) { + + } +
+
+ {{ 'calculated-fields.no-zone-configured' | translate }} +
+
+ + @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-configuration/calculated-field-geofencing-zone-groups-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.ts new file mode 100644 index 0000000000..341b898265 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.ts @@ -0,0 +1,313 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, + booleanAttribute, + 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, + 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/geofencing-configuration/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-arguments/calculated-field-arguments-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({required: true}) entityId: EntityId; + @Input({required: true}) tenantId: string; + @Input({required: true}) entityName: string; + @Input({required: true}) ownerId: EntityId; + @Input({transform: booleanAttribute}) isEditValue = true; + + @ViewChild(MatSort, { static: true }) sort: MatSort; + + errorText = ''; + zoneGroupsFormArray = this.fb.array([]); + entityNameMap = new Map(); + sortOrder = { direction: 'asc', property: '' }; + dataSource = new CalculatedFieldZoneDatasource(); + disable = false; + + 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; + } + + setDisabledState(isDisabled: boolean): void { + this.disable = isDisabled; + } + + 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, readonly: boolean = false): 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, + buttonTitle: isExists ? 'action.apply' : 'action.add', + tenantId: this.tenantId, + entityName: this.entityName, + ownerId: this.ownerId, + usedNames: this.zoneGroupsFormArray.value.map(({ name }) => name).filter(name => name !== zone.name), + readonly + }; + this.popoverComponent = this.popoverService.displayPopover({ + trigger, + renderer: this.renderer, + componentType: CalculatedFieldGeofencingZoneGroupsPanelComponent, + hostView: this.viewContainerRef, + preferredPlacement: isExists ? ['leftOnly', 'leftTopOnly', 'leftBottomOnly'] : ['rightOnly', 'rightTopOnly', 'rightBottomOnly'], + 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 { + 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({emitEvent: false}); + 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.updateDataSource(this.zoneGroupsFormArray.value); + } + + 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/geofencing-configuration/geofencing-configuration.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.html new file mode 100644 index 0000000000..25f736a222 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.html @@ -0,0 +1,70 @@ + +
+
+
+ {{ 'calculated-fields.entity-coordinates' | translate }} +
+
+ + +
+
+ +
+
+ {{ 'calculated-fields.geofencing-zone-groups' | translate }} +
+ +
+ +
+ {{ 'calculated-fields.zone-group-refresh-interval' | translate }} +
+
+
+ + +
+
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts new file mode 100644 index 0000000000..02d02241b4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts @@ -0,0 +1,171 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { booleanAttribute, Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { + ArgumentEntityType, + CalculatedFieldGeofencing, + CalculatedFieldGeofencingConfiguration, + CalculatedFieldOutput, + CalculatedFieldType, + defaultCalculatedFieldOutput, + getCalculatedFieldCurrentEntityFilter, + notEmptyObjectValidator +} from '@shared/models/calculated-field.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EntityFilter } from '@shared/models/query/query.models'; +import { EntityId } from '@shared/models/id/entity-id'; + +@Component({ + selector: 'tb-geofencing-configuration', + templateUrl: './geofencing-configuration.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => GeofencingConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => GeofencingConfigurationComponent), + multi: true + } + ], +}) +export class GeofencingConfigurationComponent implements ControlValueAccessor, Validator, OnChanges { + + @Input({required: true}) + entityId: EntityId; + + @Input({required: true}) + tenantId: string; + + @Input({required: true}) + entityName: string; + + @Input({required: true}) + ownerId: EntityId; + + @Input({transform: booleanAttribute}) isEditValue = true; + + readonly minAllowedScheduledUpdateIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedScheduledUpdateIntervalInSecForCF; + readonly DataKeyType = DataKeyType; + + geofencingConfiguration = this.fb.group({ + entityCoordinates: this.fb.group({ + latitudeKeyName: [null, [Validators.required]], + longitudeKeyName: [null, [Validators.required]], + }), + zoneGroups: this.fb.control>({}, notEmptyObjectValidator()), + scheduledUpdateEnabled: [true], + scheduledUpdateInterval: [this.minAllowedScheduledUpdateIntervalInSecForCF], + output: this.fb.control(defaultCalculatedFieldOutput) + }); + + currentEntityFilter: EntityFilter; + isRelatedEntity: boolean; + + private propagateChange: (config: CalculatedFieldGeofencingConfiguration) => void = () => { }; + + constructor(private fb: FormBuilder, + private store: Store) { + + this.geofencingConfiguration.get('zoneGroups').valueChanges + .pipe(takeUntilDestroyed()) + .subscribe((zoneGroups: Record) => + this.checkRelatedEntity(zoneGroups) + ); + + this.geofencingConfiguration.get('scheduledUpdateEnabled').valueChanges + .pipe(takeUntilDestroyed()) + .subscribe((value: boolean) => + this.checkScheduledUpdateEnabled(value) + ); + + this.geofencingConfiguration.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe(() => { + this.updatedModel(this.geofencingConfiguration.getRawValue() as any); + }) + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.entityName || changes.entityId) { + const entityNameChanges = changes.entityName; + const entityIdChanges = changes.entityId; + if ((entityNameChanges?.currentValue !== entityNameChanges?.previousValue) || (entityIdChanges?.currentValue !== entityIdChanges?.previousValue)) { + this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId); + } + } + } + + validate(): ValidationErrors | null { + return this.geofencingConfiguration.valid || this.geofencingConfiguration.disabled ? null : { geofencingConfigError: false }; + } + + writeValue(config: CalculatedFieldGeofencingConfiguration): void { + this.geofencingConfiguration.patchValue(config, {emitEvent: false}); + this.checkRelatedEntity(this.geofencingConfiguration.get('zoneGroups').value); + if (this.geofencingConfiguration.enabled) { + this.checkScheduledUpdateEnabled(this.geofencingConfiguration.get('scheduledUpdateEnabled').value); + } + } + + registerOnChange(fn: (config: CalculatedFieldGeofencingConfiguration) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_: any): void { } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.geofencingConfiguration.disable({emitEvent: false}); + } else { + this.geofencingConfiguration.enable({emitEvent: false}); + this.checkScheduledUpdateEnabled(this.geofencingConfiguration.get('scheduledUpdateEnabled').value); + } + } + + private updatedModel(value: CalculatedFieldGeofencingConfiguration) { + value.type = CalculatedFieldType.GEOFENCING; + this.propagateChange(value) + } + + private checkScheduledUpdateEnabled(value: boolean) { + if (value) { + this.geofencingConfiguration.get('scheduledUpdateInterval').enable({emitEvent: false}); + } else { + this.geofencingConfiguration.get('scheduledUpdateInterval').disable({emitEvent: false}); + } + } + + private checkRelatedEntity(zoneGroups: Record) { + this.isRelatedEntity = Object.values(zoneGroups).some(zone => zone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module.ts new file mode 100644 index 0000000000..a7b9b49e1c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module.ts @@ -0,0 +1,52 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + CalculatedFieldGeofencingZoneGroupsTableComponent +} from '@home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component'; +import { + CalculatedFieldGeofencingZoneGroupsPanelComponent +} from '@home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component'; +import { SharedModule } from '@shared/shared.module'; +import { + GeofencingConfigurationComponent +} from '@home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component'; +import { + CalculatedFieldOutputModule +} from '@home/components/calculated-fields/components/output/calculated-field-output.module'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CalculatedFieldOutputModule + ], + declarations: [ + CalculatedFieldGeofencingZoneGroupsTableComponent, + CalculatedFieldGeofencingZoneGroupsPanelComponent, + GeofencingConfigurationComponent + ], + exports: [ + CalculatedFieldGeofencingZoneGroupsTableComponent, + CalculatedFieldGeofencingZoneGroupsPanelComponent, + GeofencingConfigurationComponent + ] +}) +export class GeofencingConfigurationModule { + +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html new file mode 100644 index 0000000000..00ae7685e3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html @@ -0,0 +1,226 @@ + +
+
{{ 'calculated-fields.metrics.metric-settings' | translate }}
+
+
+
{{ 'calculated-fields.metrics.metric-name' | translate }}
+ + + @if (metricForm.get('name').touched && metricForm.get('name').hasError('required')) { + + warning + + } @else if (metricForm.get('name').touched && metricForm.get('name').hasError('duplicateName')) { + + warning + + } @else if (metricForm.get('name').touched && metricForm.get('name').hasError('pattern')) { + + warning + + } @else if (metricForm.get('name').touched && metricForm.get('name').hasError('maxlength')) { + + warning + + } @else if (metricForm.get('name').touched && metricForm.get('name').hasError('forbiddenName')) { + + warning + + } + +
+
+
{{ 'calculated-fields.metrics.aggregation' | translate }}
+ + + @for (aggFunction of AggFunctions; track aggFunction) { + {{ AggFunctionTranslations.get(aggFunction) | translate }} + } + + +
+ + @if (!simpleMode) { +
+ + + + +
+ {{ 'calculated-fields.metrics.filter' | translate }} +
+
+
+
+ + +
{{ 'api-usage.tbel' | translate }} +
+ +
+
+ +
+
+
+
+ } + + @if (!simpleMode) { +
+
+ {{ 'calculated-fields.metrics.value-source' | translate }} +
+ + + @for (inputType of AggInputTypes; track inputType) { + {{ AggInputTypeTranslations.get(inputType) | translate }} + } + + +
+ } + @if (this.metricForm.get('input.type').value === AggInputType.key) { +
+
{{ 'calculated-fields.argument-name' | translate }}
+ + + @for (argument of arguments; track argument) { + {{ argument }} + } + + @if (metricForm.get('input.key').touched && metricForm.get('input.key').hasError('required')) { + + warning + + } + +
+ } @else { + +
{{ 'api-usage.tbel' | translate }} +
+ +
+
+ +
+ } +
+ @if (simpleMode) { +
+
{{ 'calculated-fields.default-value' | translate }}
+ + + +
+ } +
+
+ + @if (!readonly) { + + } +
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts new file mode 100644 index 0000000000..4f98a7225f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts @@ -0,0 +1,186 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Input, OnInit, output } from '@angular/core'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { FormBuilder, Validators } from '@angular/forms'; +import { charsWithNumRegex } from '@shared/models/regex.constants'; +import { + AggFunction, + AggFunctionTranslations, + AggInputType, + AggInputTypeTranslations, + CalculatedFieldAggMetricValue, + calculatedFieldMetricFilterDefaultScript, + calculatedFieldMetricMapDefaultScript, + FORBIDDEN_NAMES, + forbiddenNamesValidator, + uniqueNameValidator +} from '@shared/models/calculated-field.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EntityFilter } from '@shared/models/query/query.models'; +import { ScriptLanguage } from '@shared/models/rule-node.models'; +import { TbEditorCompleter } from '@shared/models/ace/completion.models'; +import { AceHighlightRules } from '@shared/models/ace/ace.models'; +import { Observable } from "rxjs"; + +interface CalculatedFieldAggMetricValuePanel extends CalculatedFieldAggMetricValue { + allowFilter: boolean; +} + +@Component({ + selector: 'tb-calculated-field-metrics-panel', + templateUrl: './calculated-field-metrics-panel.component.html', + styleUrls: ['../common/calculated-field-panel.scss', '../../calculated-field.component.scss'], +}) +export class CalculatedFieldMetricsPanelComponent implements OnInit { + + @Input() buttonTitle: string; + @Input() metric: CalculatedFieldAggMetricValue; + @Input() usedNames: string[]; + @Input() arguments: Array; + @Input() simpleMode: boolean; + @Input() editorCompleter: TbEditorCompleter; + @Input() highlightRules: AceHighlightRules; + @Input({required: true}) testScript: (expression?: string) => Observable; + @Input() readonly = false; + + metricDataApplied = output(); + filterExpanded = false; + functionArgs: Array + + metricForm = this.fb.group({ + name: ['', [Validators.required, forbiddenNamesValidator(FORBIDDEN_NAMES), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], + function: [AggFunction.AVG], + allowFilter: [false], + filter: [calculatedFieldMetricFilterDefaultScript, Validators.required], + input: this.fb.group({ + type: [AggInputType.key], + key: ['', Validators.required], + function: [calculatedFieldMetricMapDefaultScript, Validators.required], + }), + defaultValue: [null] + }); + + entityFilter: EntityFilter; + + AggFunctions = Object.values(AggFunction) as AggFunction[]; + readonly AggFunctionTranslations = AggFunctionTranslations; + readonly ScriptLanguage = ScriptLanguage; + readonly AggInputType = AggInputType; + readonly AggInputTypes = Object.values(AggInputType) as AggInputType[]; + readonly AggInputTypeTranslations = AggInputTypeTranslations; + + constructor( + private fb: FormBuilder, + private popover: TbPopoverComponent, + ) { + this.observeFilterAllowChange(); + this.observeInputTypeChange(); + } + + ngOnInit(): void { + this.updatedForm(); + + const data: CalculatedFieldAggMetricValuePanel = { + ...this.metric, + allowFilter: !!this.metric.filter, + } + this.metricForm.patchValue(data, {emitEvent: false}); + + this.validateFilter(data.allowFilter); + this.validateInputTypeFilter(data.input?.type ?? AggInputType.key); + this.validateInputKey(); + + this.functionArgs = ['ctx', ...this.arguments]; + + if (this.simpleMode) { + this.AggFunctions = this.AggFunctions.filter(aggFunc => aggFunc !== AggFunction.COUNT_UNIQUE); + } + + if (this.readonly) { + this.metricForm.disable({emitEvent: false}); + } + } + + saveMetric(): void { + const value = this.metricForm.value as CalculatedFieldAggMetricValuePanel; + if (!value.allowFilter) { + delete value.filter; + } + delete value.allowFilter; + this.metricDataApplied.emit(value); + } + + cancel(): void { + this.popover.hide(); + } + + private updatedForm(): void { + this.metricForm.get('name').addValidators(uniqueNameValidator(this.usedNames)); + this.metricForm.get('name').updateValueAndValidity({emitEvent: false}); + + if (!this.simpleMode) { + this.metricForm.removeControl('defaultValue', {emitEvent: false}); + } + } + + private observeFilterAllowChange(): void { + this.metricForm.get('allowFilter').valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(value => this.validateFilter(value)); + } + + private observeInputTypeChange(): void { + this.metricForm.get('input.type').valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(value => this.validateInputTypeFilter(value)); + } + + private validateFilter(allowFilter = false): void { + if (allowFilter) { + this.metricForm.get('filter').enable({emitEvent: false}); + } else { + this.metricForm.get('filter').disable({emitEvent: false}); + } + this.filterExpanded = allowFilter; + } + + private validateInputTypeFilter(value: AggInputType): void { + const inputForm = this.metricForm.get('input'); + if (value === AggInputType.key) { + inputForm.get('key').enable({emitEvent: false}); + inputForm.get('function').disable({emitEvent: false}); + } else { + inputForm.get('key').disable({emitEvent: false}); + inputForm.get('function').enable({emitEvent: false}); + } + } + + private validateInputKey() { + if (this.metric.input?.type === AggInputType.key && !this.arguments.includes(this.metric.input.key)) { + this.metricForm.get('input.key').setValue(null); + this.metricForm.get('input.key').markAsTouched(); + } + } + + onTestScript(scriptFunc: 'filter' | 'input.function') { + this.testScript(this.metricForm.get(scriptFunc).value).subscribe(expression => { + this.metricForm.get(scriptFunc).setValue(expression); + this.metricForm.get(scriptFunc).markAsDirty(); + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.html new file mode 100644 index 0000000000..f26387d5da --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.html @@ -0,0 +1,151 @@ + +
+
+ + + +
{{ 'calculated-fields.metrics.metric-name' | translate }}
+
+ +
+
{{ metric.name }}
+ +
+
+
+ + + {{ 'calculated-fields.metrics.aggregation' | translate }} + + +
{{ AggFunctionTranslations.get(metric.function) | translate }}
+
+
+ + + {{ 'calculated-fields.metrics.argument-name' | translate }} + + +
{{ metric.input.key }}
+
+
+ + + {{ 'calculated-fields.metrics.filtered' | translate }} + + +
+ {{ metric.filter ? 'check_box' : 'check_box_outline_blank' }} +
+
+
+ + + {{ 'calculated-fields.metrics.value-source' | translate }} + + +
{{ AggInputTypeTranslations.get(metric.input.type) | translate }}
+
+
+ + + + +
+ @if (disable) { + + } @else { + + } + +
+
+
+ + +
+
+
+ {{ 'calculated-fields.metrics.no-metrics-configured' | translate }} +
+
+ + @if (maxArgumentsPerCF && metricsFormArray.length >= maxArgumentsPerCF) { +
+ warning + {{ 'calculated-fields.metrics.max-metrics' | translate }} +
+ } +
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts new file mode 100644 index 0000000000..7862de3a33 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts @@ -0,0 +1,263 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, + booleanAttribute, + ChangeDetectorRef, + Component, + DestroyRef, + forwardRef, + Input, + OnInit, + Renderer2, + ViewChild, + ViewContainerRef, +} from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, +} from '@angular/forms'; +import { + AggFunctionTranslations, + AggInputTypeTranslations, + CalculatedFieldAggMetric, + CalculatedFieldAggMetricValue, +} from '@shared/models/calculated-field.models'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { isDefinedAndNotNull, isEqual } from '@core/utils'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract'; +import { MatSort, SortDirection } from '@angular/material/sort'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + CalculatedFieldMetricsPanelComponent +} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component'; +import { TbEditorCompleter } from '@shared/models/ace/completion.models'; +import { AceHighlightRules } from '@shared/models/ace/ace.models'; +import { Observable } from "rxjs"; + +@Component({ + selector: 'tb-calculated-field-metrics-table', + templateUrl: './calculated-field-metrics-table.component.html', + styleUrls: [`../calculated-field-arguments/calculated-field-arguments-table.component.scss`], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CalculatedFieldMetricsTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CalculatedFieldMetricsTableComponent), + multi: true + } + ], +}) +export class CalculatedFieldMetricsTableComponent implements OnInit, ControlValueAccessor, Validator, AfterViewInit { + + @Input() arguments: Array; + @Input() editorCompleter: TbEditorCompleter; + @Input() highlightRules: AceHighlightRules; + @Input({transform: booleanAttribute}) simpleMode: boolean = false; + @Input({required: true}) testScript: (expression?: string) => Observable; + + @ViewChild(MatSort, { static: true }) sort: MatSort; + + errorText = ''; + metricsFormArray = this.fb.array([]); + sortOrder = { direction: 'asc' as SortDirection, property: '' }; + dataSource = new CalculatedFieldMetricsDatasource(); + disable = false; + + displayColumns = ['name', 'function', 'filter', 'valueSource', 'actions']; + + readonly AggFunctionTranslations = AggFunctionTranslations; + readonly AggInputTypeTranslations = AggInputTypeTranslations; + readonly maxArgumentsPerCF = getCurrentAuthState(this.store).maxArgumentsPerCF - 2; + + private popoverComponent: TbPopoverComponent; + private propagateChange: (zonesObj: Record) => void = () => {}; + + constructor( + private fb: FormBuilder, + private popoverService: TbPopoverService, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef, + private renderer: Renderer2, + private destroyRef: DestroyRef, + private store: Store + ) { + this.metricsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => { + this.updateDataSource(value); + this.propagateChange(this.getMetricsObject(value)); + }); + } + + ngOnInit() { + if (this.simpleMode) { + this.displayColumns = ['name', 'function', 'argumentName', 'actions']; + } + } + + ngAfterViewInit(): void { + this.sort.sortChange.asObservable().pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.sortOrder.property = this.sort.active; + this.sortOrder.direction = this.sort.direction; + this.updateDataSource(this.metricsFormArray.value); + }); + } + + registerOnChange(fn: (zonesObj: Record) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void {} + + validate(): ValidationErrors | null { + this.updateErrorText(); + return this.errorText ? { metricsFormArray: false } : null; + } + + setDisabledState(isDisabled: boolean): void { + this.disable = isDisabled; + } + + onDelete($event: Event, metric: CalculatedFieldAggMetricValue): void { + $event.stopPropagation(); + const index = this.metricsFormArray.controls.findIndex(control => isEqual(control.value, metric)); + this.metricsFormArray.removeAt(index); + this.metricsFormArray.markAsDirty(); + } + + manageMetrics($event: Event, matButton: MatButton, metric = {} as CalculatedFieldAggMetricValue, readonly: boolean = false): void { + $event?.stopPropagation(); + if (this.popoverComponent && !this.popoverComponent.tbHidden) { + this.popoverComponent.hide(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const index = this.metricsFormArray.controls.findIndex(control => isEqual(control.value, metric)); + const isExists = index !== -1; + const ctx = { + index, + metric, + buttonTitle: isExists ? 'action.apply' : 'action.add', + usedNames: this.metricsFormArray.value.map(({ name }) => name).filter(name => name !== metric.name), + arguments: this.arguments, + editorCompleter: this.editorCompleter, + highlightRules: this.highlightRules, + simpleMode: this.simpleMode, + testScript: this.testScript, + readonly + }; + this.popoverComponent = this.popoverService.displayPopover({ + trigger, + renderer: this.renderer, + componentType: CalculatedFieldMetricsPanelComponent, + hostView: this.viewContainerRef, + preferredPlacement: isExists ? ['leftOnly', 'leftTopOnly', 'leftBottomOnly'] : ['rightOnly', 'rightTopOnly', 'rightBottomOnly'], + context: ctx, + isModal: true + }); + this.popoverComponent.tbComponentRef.instance.metricDataApplied.subscribe((value) => { + this.popoverComponent.hide(); + if (isExists) { + this.metricsFormArray.at(index).setValue(value); + } else { + this.metricsFormArray.push(this.fb.control(value)); + } + this.cd.markForCheck(); + }); + } + } + + private updateDataSource(value: CalculatedFieldAggMetricValue[]): void { + const sortedValue = this.sortData(value); + this.dataSource.loadData(sortedValue); + } + + private updateErrorText(): void { + if (!this.metricsFormArray.controls.length) { + this.errorText = 'calculated-fields.metrics.metrics-empty'; + } else { + this.errorText = ''; + } + } + + private getMetricsObject(value: CalculatedFieldAggMetricValue[]): Record { + return value.reduce((acc, metricValue) => { + const { name, ...metric } = metricValue; + acc[name] = metric; + return acc; + }, {} as Record); + } + + writeValue(metrics: Record): void { + this.metricsFormArray.clear({emitEvent: false}); + this.populateZonesFormArray(metrics); + } + + private populateZonesFormArray(metrics: Record): void { + Object.keys(metrics).forEach(key => { + const value: CalculatedFieldAggMetricValue = { + ...metrics[key], + name: key + }; + this.metricsFormArray.push(this.fb.control(value), { emitEvent: false }); + }); + this.updateDataSource(this.metricsFormArray.value); + } + + private getSortValue(metric: CalculatedFieldAggMetricValue, column: string): string { + switch (column) { + case 'function': + return metric.function; + case 'valueSource': + return metric.input?.type; + case 'filter': + return isDefinedAndNotNull(metric.filter).toString(); + default: + return metric.name; + } + } + + private sortData(data: CalculatedFieldAggMetricValue[]): CalculatedFieldAggMetricValue[] { + return data.sort((a, b) => { + const valA = this.getSortValue(a, this.sortOrder.property) ?? ''; + const valB = this.getSortValue(b, this.sortOrder.property) ?? ''; + return (this.sortOrder.direction === 'asc' ? 1 : -1) * valA.localeCompare(valB); + }); + } +} + +class CalculatedFieldMetricsDatasource extends TbTableDatasource { + constructor() { + super(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.module.ts new file mode 100644 index 0000000000..ee72828bc8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.module.ts @@ -0,0 +1,42 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + CalculatedFieldMetricsTableComponent +} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component'; +import { + CalculatedFieldMetricsPanelComponent +} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + ], + declarations: [ + CalculatedFieldMetricsTableComponent, + CalculatedFieldMetricsPanelComponent + ], + exports: [ + CalculatedFieldMetricsTableComponent + ] +}) +export class CalculatedFieldMetricsTableModule { + +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html new file mode 100644 index 0000000000..8a6bc2abe8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html @@ -0,0 +1,179 @@ + +
+
+ {{ 'calculated-fields.output' | translate }} +
+
+
+ + {{ 'calculated-fields.output-type' | translate }} + + @for (type of outputTypes; track type) { + {{ OutputTypeTranslations.get(type) | translate }} + } + + + @if (outputForm.get('type').value === OutputType.Attribute + && (entityId?.entityType === EntityType.DEVICE || entityId?.entityType === EntityType.DEVICE_PROFILE)) { + + {{ 'calculated-fields.attribute-scope' | translate }} + + + {{ 'calculated-fields.server-attributes' | translate }} + + + {{ 'calculated-fields.shared-attributes' | translate }} + + + + } +
+ @if (simpleMode) { + @if (hiddenName) { +
+ + +
+ } @else { +
+ + {{ (outputForm.get('type').value === OutputType.Timeseries + ? 'calculated-fields.timeseries-key' + : 'calculated-fields.attribute-key') | translate }} + + @if (outputForm.get('name').errors && outputForm.get('name').touched) { + + @if (outputForm.get('name').hasError('required')) { + {{ 'common.hint.key-required' | translate }} + } @else if (outputForm.get('name').hasError('pattern')) { + {{ 'common.hint.key-pattern' | translate }} + } @else if (outputForm.get('name').hasError('maxlength')) { + {{ 'common.hint.key-max-length' | translate }} + } + + } + + +
+ + } + } +
+
+ + + +
+
+ {{ 'calculated-fields.output-strategy.strategy' | translate }} +
+ + @for (outputStrategyType of OutputStrategyTypes; track outputStrategyType) { + {{ OutputStrategyTypeTranslations.get(outputStrategyType) | translate }} + } + +
+
+
+ @if (outputForm.get('strategy.type').value === OutputStrategyType.IMMEDIATE) { +
+
calculated-fields.output-strategy.processing-parameters
+ @if (outputForm.get('type').value === OutputType.Timeseries) { + +
+
calculated-fields.output-strategy.save-time-series
+
+
+ +
+
calculated-fields.output-strategy.save-latest-values
+
+
+ } @else { + +
+
calculated-fields.output-strategy.save-database
+
+
+ } + +
+
calculated-fields.output-strategy.send-web-sockets
+
+
+ +
+
calculated-fields.output-strategy.save-calculated-fields
+
+
+
+ @if (outputForm.get('type').value === OutputType.Attribute) { +
+ +
+
calculated-fields.output-strategy.update-attribute-only-on-value-change
+
+
+
+
+ +
+
calculated-fields.output-strategy.send-attributes-updated-notification
+
+
+
+ } @else { +
+ +
+
calculated-fields.output-strategy.ttl
+
+
+ + +
+ } + } +
+
+
+ + + {{ 'calculated-fields.decimals-by-default' | translate }} + + @if (outputForm.get('decimalsByDefault').errors && outputForm.get('decimalsByDefault').touched) { + {{ 'calculated-fields.hint.decimals-range' | translate }} + } + + + + + diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.scss new file mode 100644 index 0000000000..b0d282b7f4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.scss @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + .mat-mdc-chip-disabled { + .mdc-evolution-chip__action { + cursor: default + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts new file mode 100644 index 0000000000..a9924909b7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts @@ -0,0 +1,265 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, forwardRef, inject, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { + CalculatedFieldOutput, + CalculatedFieldSimpleOutput, + OutputStrategyType, + OutputStrategyTypeTranslations, + OutputType, + OutputTypeTranslations +} from '@shared/models/calculated-field.models'; +import { digitsRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { merge } from 'rxjs'; +import { deepClone } from '@core/utils'; + +@Component({ + selector: 'tb-calculate-field-output', + templateUrl: './calculated-field-output.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CalculatedFieldOutputComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CalculatedFieldOutputComponent), + multi: true + } + ], + styleUrls: ['./calculated-field-output.component.scss'], +}) +export class CalculatedFieldOutputComponent implements ControlValueAccessor, Validator, OnInit, OnChanges { + + @Input() + @coerceBoolean() + simpleMode = false; + + @Input() + @coerceBoolean() + hiddenName = false; + + @Input() + @coerceBoolean() + disableType = false; + + @Input() + containerInputClass: string | string[] | Record = 'flex flex-col gap-3'; + + @Input({required: true}) + entityId: EntityId; + + disabled = false; + + readonly outputTypes = Object.values(OutputType) as OutputType[]; + readonly OutputType = OutputType; + readonly AttributeScope = AttributeScope; + readonly OutputTypeTranslations = OutputTypeTranslations; + readonly EntityType = EntityType; + + readonly OutputStrategyType = OutputStrategyType; + readonly OutputStrategyTypes = Object.values(OutputStrategyType) as OutputStrategyType[]; + readonly OutputStrategyTypeTranslations = OutputStrategyTypeTranslations; + + private fb = inject(FormBuilder); + private destroyRef = inject(DestroyRef); + + outputForm = this.fb.group({ + name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], + scope: [{value: AttributeScope.SERVER_SCOPE, disabled: true}], + type: [OutputType.Timeseries], + decimalsByDefault: [null as number, [Validators.min(0), Validators.max(15), Validators.pattern(digitsRegex)]], + strategy: this.fb.group({ + type: [OutputStrategyType.IMMEDIATE], + saveTimeSeries: [true], + saveLatest: [true], + saveAttribute: [true], + sendWsUpdate: [true], + processCfs: [true], + updateAttributesOnlyOnValueChange: [true], + sendAttributesUpdatedNotification: [false], + useCustomTtl: [false], + ttl: [0] + }) + }); + + private propagateChange: (config: CalculatedFieldOutput | CalculatedFieldSimpleOutput) => void = () => { }; + + ngOnInit() { + this.outputForm.get('type').valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(type => { + this.toggleScopeByOutputType(type); + this.updatedStrategy(); + }); + + this.outputForm.get('strategy.saveTimeSeries').valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(value => this.updateTimeSeriesTtl(value)); + + merge( + this.outputForm.get('strategy.type').valueChanges, + this.outputForm.get('strategy.useCustomTtl').valueChanges + ) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.updatedStrategy(); + }); + + this.updatedFormWithMode(); + + this.outputForm.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((value: CalculatedFieldOutput | CalculatedFieldSimpleOutput) => { + this.updatedModel(value); + }) + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (change.currentValue !== change.previousValue) { + if (propName === 'simpleMode' && !this.disabled) { + this.updatedFormWithMode(); + if (!change.firstChange) { + this.outputForm.updateValueAndValidity(); + } + } + } + } + } + + validate(): ValidationErrors | null { + return this.outputForm.valid || this.outputForm.disabled ? null : {outputConfig: false}; + } + + writeValue(value: CalculatedFieldOutput | CalculatedFieldSimpleOutput): void { + this.outputForm.patchValue(value, {emitEvent: false}); + if (value.type === OutputType.Timeseries && value.strategy?.type === OutputStrategyType.IMMEDIATE && value.strategy?.ttl) { + this.outputForm.get('strategy.useCustomTtl').setValue(true, {emitEvent: false}); + } + this.outputForm.get('type').updateValueAndValidity({onlySelf: true, emitEvent: false}); + } + + registerOnChange(fn: (config: CalculatedFieldOutput | CalculatedFieldSimpleOutput) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_: any): void { } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.outputForm.disable({emitEvent: false}); + } else { + this.outputForm.enable({emitEvent: false}); + this.updatedFormWithMode(); + this.toggleScopeByOutputType(this.outputForm.get('type').value); + this.updatedStrategy(); + this.updateTimeSeriesTtl(this.outputForm.get('strategy.saveTimeSeries').value); + if (this.outputForm.invalid) { + this.outputForm.updateValueAndValidity(); + } + } + } + + private updatedModel(value: CalculatedFieldOutput | CalculatedFieldSimpleOutput) { + if (this.simpleMode && 'name' in value) { + value.name = value.name?.trim() ?? ''; + } + if (this.disableType) { + value.type = this.outputForm.get('type').value; + } + if (value.type === OutputType.Timeseries && value.strategy.type === OutputStrategyType.IMMEDIATE) { + value = deepClone(value); + delete (value.strategy as any).useCustomTtl + } + this.propagateChange(value); + } + + private toggleScopeByOutputType(type: OutputType): void { + if (type === OutputType.Attribute) { + this.outputForm.get('scope').enable({emitEvent: false}); + } else { + this.outputForm.get('scope').disable({emitEvent: false}); + } + } + + private updatedFormWithMode(): void { + if (this.simpleMode && !this.hiddenName) { + this.outputForm.get('name').enable({emitEvent: false}); + } else { + this.outputForm.get('name').disable({emitEvent: false}); + } + if (this.simpleMode) { + this.outputForm.get('decimalsByDefault').enable({emitEvent: false}); + } else { + this.outputForm.get('decimalsByDefault').disable({emitEvent: false}); + } + if (this.disableType) { + this.outputForm.get('type').disable({emitEvent: false}); + } + } + + private updatedStrategy(): void { + const strategyType = this.outputForm.get('strategy.type').value; + this.outputForm.get('strategy').disable({emitEvent: false}); + this.outputForm.get('strategy.type').enable({emitEvent: false}); + + if (strategyType === OutputStrategyType.IMMEDIATE) { + const outputType = this.outputForm.get('type').value; + this.outputForm.get('strategy.sendWsUpdate').enable({emitEvent: false}); + this.outputForm.get('strategy.processCfs').enable({emitEvent: false}); + if (outputType === OutputType.Attribute) { + this.outputForm.get('strategy.saveAttribute').enable({emitEvent: false}); + this.outputForm.get('strategy.updateAttributesOnlyOnValueChange').enable({emitEvent: false}); + this.outputForm.get('strategy.sendAttributesUpdatedNotification').enable({emitEvent: false}); + } else { + this.outputForm.get('strategy.saveTimeSeries').enable({emitEvent: false}); + this.outputForm.get('strategy.saveLatest').enable({emitEvent: false}); + this.outputForm.get('strategy.useCustomTtl').enable({emitEvent: false}); + if (this.outputForm.get('strategy.useCustomTtl').value) { + this.outputForm.get('strategy.ttl').enable({emitEvent: false}); + } + } + } + } + + private updateTimeSeriesTtl(value: boolean) { + if (value) { + this.outputForm.get('strategy.useCustomTtl').enable({emitEvent: false}); + } else { + this.outputForm.get('strategy.useCustomTtl').disable({emitEvent: false}); + this.outputForm.get('strategy.ttl').disable({emitEvent: false}); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.module.ts new file mode 100644 index 0000000000..848aa1d5de --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.module.ts @@ -0,0 +1,36 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + CalculatedFieldOutputComponent +} from '@home/components/calculated-fields/components/output/calculated-field-output.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + ], + declarations: [ + CalculatedFieldOutputComponent + ], + exports: [ + CalculatedFieldOutputComponent + ] +}) +export class CalculatedFieldOutputModule { } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html deleted file mode 100644 index 92855d882f..0000000000 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html +++ /dev/null @@ -1,209 +0,0 @@ - -
-
-
{{ 'calculated-fields.argument-settings' | translate }}
-
-
-
{{ 'calculated-fields.argument-name' | translate }}
- - - @if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('required')) { - - warning - - } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('duplicateName')) { - - warning - - } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('pattern')) { - - warning - - } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('maxlength')) { - - warning - - } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('forbiddenName')) { - - warning - - } - -
- -
-
{{ '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.argument-type' | translate }}
- - - @for (type of argumentTypes; track type) { - {{ ArgumentTypeTranslations.get(type) | translate }} - } - - @if (refEntityKeyFormGroup.get('type').hasError('required') && refEntityKeyFormGroup.get('type').touched) { - - warning - - } - -
- @if (entityFilter.singleEntity?.id || entityType === ArgumentEntityType.Current || entityType === ArgumentEntityType.Tenant) { - @if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Attribute) { -
-
{{ 'calculated-fields.timeseries-key' | translate }}
- @if (refEntityKeyFormGroup.get('type').value === ArgumentType.LatestTelemetry) { - - } @else { - - } - - - -
- } @else { - @if (enableAttributeScopeSelection) { -
-
{{ 'calculated-fields.attribute-scope' | translate }}
- - - - {{ 'calculated-fields.server-attributes' | translate }} - - - {{ 'calculated-fields.client-attributes' | translate }} - - - {{ 'calculated-fields.shared-attributes' | translate }} - - - -
- } -
-
{{ 'calculated-fields.attribute-key' | translate }}
- -
- } - } -
- @if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Rolling) { -
-
{{ 'calculated-fields.default-value' | translate }}
- - - -
- } @else { -
-
{{ 'calculated-fields.time-window' | translate }}
- -
- @if (maxDataPointsPerRollingArg) { -
-
{{ 'calculated-fields.limit' | translate }}
-
- - - - - - -
-
- } - } -
-
-
- - -
-
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html new file mode 100644 index 0000000000..475f956b07 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html @@ -0,0 +1,100 @@ + +
+
+
+ {{ 'calculated-fields.propagation-path-related-entities' | translate }} +
+
+ + {{ 'calculated-fields.direction' | translate }} + + @for (direction of Directions; track direction) { + {{ PropagationDirectionTranslations.get(direction) | translate }} + } + + + + +
+
+
+
+
+ {{ 'calculated-fields.data-propagate' | translate }} +
+ + {{ 'calculated-fields.propagate-type.arguments-only' | translate }} + {{ 'calculated-fields.propagate-type.expression-result' | translate }} + +
+ +
+
+
{{ 'calculated-fields.script' | translate }}
+
+ +
{{ 'api-usage.tbel' | translate }} +
+ +
+
+ +
+
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts new file mode 100644 index 0000000000..1cf2476c2e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts @@ -0,0 +1,190 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { booleanAttribute, Component, forwardRef, Input } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { EntityId } from '@shared/models/id/entity-id'; +import { Observable, of } from 'rxjs'; +import { + calculatedFieldDefaultScript, + CalculatedFieldOutput, + CalculatedFieldPropagationConfiguration, + CalculatedFieldType, + defaultCalculatedFieldOutput, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights, + notEmptyObjectValidator, + OutputType, + PropagationDirectionTranslations, + PropagationWithExpression +} from '@shared/models/calculated-field.models'; +import { map } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ScriptLanguage } from '@app/shared/models/rule-node.models'; +import { EntitySearchDirection } from '@shared/models/relation.models'; +import {Store} from "@ngrx/store"; +import {AppState} from "@core/core.state"; +import {getCurrentAuthState} from "@core/auth/auth.selectors"; + +@Component({ + selector: 'tb-propagation-configuration', + templateUrl: './propagation-configuration.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PropagationConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => PropagationConfigurationComponent), + multi: true + } + ], +}) +export class PropagationConfigurationComponent implements ControlValueAccessor, Validator { + + @Input({required: true}) + entityId: EntityId; + + @Input({required: true}) + tenantId: string; + + @Input({required: true}) + entityName: string; + + @Input({required: true}) + ownerId: EntityId; + + @Input({required: true}) + testScript: () => Observable; + + @Input({transform: booleanAttribute}) isEditValue = true; + + readonly maxRelatedEntitiesToReturnPerCfArgument = getCurrentAuthState(this.store).maxRelatedEntitiesToReturnPerCfArgument; + + propagateConfiguration = this.fb.group({ + arguments: this.fb.control({}, notEmptyObjectValidator()), + applyExpressionToResolvedArguments: [false], + relation: this.fb.group({ + direction: [EntitySearchDirection.TO, Validators.required], + relationType: ['Contains', Validators.required], + }), + expression: [calculatedFieldDefaultScript], + output: this.fb.control(defaultCalculatedFieldOutput), + }); + + disabled = false; + + readonly ScriptLanguage = ScriptLanguage; + readonly CalculatedFieldType = CalculatedFieldType; + readonly OutputType = OutputType; + readonly Directions = Object.values(EntitySearchDirection) as Array; + readonly PropagationDirectionTranslations = PropagationDirectionTranslations; + + functionArgs$ = this.propagateConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)]) + ); + + argumentsEditorCompleter$ = this.propagateConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj ?? {})) + ); + + argumentsHighlightRules$ = this.propagateConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj)) + ); + + private propagateChange: (config: CalculatedFieldPropagationConfiguration) => void = () => { }; + + constructor(private fb: FormBuilder, + private store: Store) { + this.propagateConfiguration.get('applyExpressionToResolvedArguments').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe(() => { + this.updatedFormWithScript(); + }) + + this.propagateConfiguration.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((value: CalculatedFieldPropagationConfiguration) => { + this.updatedModel(value); + }) + } + + validate(): ValidationErrors | null { + return this.propagateConfiguration.valid || this.propagateConfiguration.disabled ? null : {invalidPropagateConfig: false}; + } + + writeValue(value: PropagationWithExpression): void { + value.expression = value.expression ?? calculatedFieldDefaultScript; + this.propagateConfiguration.patchValue(value, {emitEvent: false}); + if (!this.disabled) { + this.updatedFormWithScript(); + } + setTimeout(() => { + this.propagateConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); + }); + } + + registerOnChange(fn: (config: CalculatedFieldPropagationConfiguration) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_: any): void { } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.propagateConfiguration.disable({emitEvent: false}); + } else { + this.propagateConfiguration.enable({emitEvent: false}); + this.updatedFormWithScript(); + } + } + + onTestScript() { + this.testScript().subscribe((expression) => { + this.propagateConfiguration.get('expression').setValue(expression); + this.propagateConfiguration.get('expression').markAsDirty(); + }) + } + + fetchOptions(searchText: string): Observable> { + const search = searchText ? searchText?.toLowerCase() : ''; + return of(['Contains', 'Manages']).pipe(map(name => name?.filter(option => option.toLowerCase().includes(search)))); + } + + private updatedModel(value: CalculatedFieldPropagationConfiguration): void { + value.type = CalculatedFieldType.PROPAGATION; + this.propagateChange(value); + } + + private updatedFormWithScript() { + if (this.propagateConfiguration.get('applyExpressionToResolvedArguments').value) { + this.propagateConfiguration.get('expression').enable({emitEvent: false}); + } else { + this.propagateConfiguration.get('expression').disable({emitEvent: false}); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module.ts new file mode 100644 index 0000000000..209f27eec1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module.ts @@ -0,0 +1,44 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + CalculatedFieldOutputModule +} from '@home/components/calculated-fields/components/output/calculated-field-output.module'; +import { + CalculatedFieldArgumentsTableModule +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module'; +import { + PropagationConfigurationComponent +} from '@home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CalculatedFieldOutputModule, + CalculatedFieldArgumentsTableModule, + ], + declarations: [ + PropagationConfigurationComponent, + ], + exports: [ + PropagationConfigurationComponent, + ] +}) +export class PropagationConfigurationModule { } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts index 9e3c52bc4f..476714e3cb 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -15,7 +15,4 @@ /// export * from './dialog/calculated-field-dialog.component'; -export * from './arguments-table/calculated-field-arguments-table.component'; -export * from './panel/calculated-field-argument-panel.component'; -export * from './debug-dialog/calculated-field-debug-dialog.component'; export * from './test-dialog/calculated-field-script-test-dialog.component'; diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html new file mode 100644 index 0000000000..f3ac0eefd8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html @@ -0,0 +1,86 @@ + +
+
+
+ {{ 'calculated-fields.aggregation-path-related-entities' | translate }} +
+
+ + {{ 'calculated-fields.direction' | translate }} + + @for (direction of Directions; track direction) { + {{ PropagationDirectionTranslations.get(direction) | translate }} + } + + + + +
+
+
+
+ {{ 'calculated-fields.arguments' | translate }} +
+ +
+
+
+ {{ 'calculated-fields.metrics.metrics' | translate }} +
+ + + +
+ + @if (relatedAggregationConfiguration.get('output').value?.type === OutputType.Timeseries) { +
+ +
+
calculated-fields.use-latest-timestamp
+
+
+
+ } +
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.scss new file mode 100644 index 0000000000..d012d20732 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.scss @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + .simpleMode { + min-width: 0; + + .mat-slide { + overflow: hidden; + + .mdc-form-field { + width: 100%; + + .mdc-label { + min-width: 0; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts new file mode 100644 index 0000000000..0e21263765 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts @@ -0,0 +1,161 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { booleanAttribute, Component, forwardRef, Input } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { EntityId } from '@shared/models/id/entity-id'; +import { Observable, of } from 'rxjs'; +import { + CalculatedFieldOutput, + CalculatedFieldRelatedAggregationConfiguration, + CalculatedFieldType, + defaultCalculatedFieldOutput, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights, + notEmptyObjectValidator, + OutputType, + PropagationDirectionTranslations +} from '@shared/models/calculated-field.models'; +import { map } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ScriptLanguage } from '@app/shared/models/rule-node.models'; +import { EntitySearchDirection } from '@shared/models/relation.models'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; + +@Component({ + selector: 'tb-related-entities-aggregation-component', + templateUrl: './related-entities-aggregation-component.component.html', + styleUrl: './related-entities-aggregation-component.component.scss', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RelatedEntitiesAggregationComponentComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => RelatedEntitiesAggregationComponentComponent), + multi: true + } + ], +}) +export class RelatedEntitiesAggregationComponentComponent implements ControlValueAccessor, Validator { + + @Input({required: true}) + entityId: EntityId; + + @Input({required: true}) + tenantId: string; + + @Input({required: true}) + entityName: string; + + @Input({required: true}) + testScript: (expression?: string) => Observable; + + @Input({transform: booleanAttribute}) isEditValue = true; + + readonly ScriptLanguage = ScriptLanguage; + readonly CalculatedFieldType = CalculatedFieldType; + readonly OutputType = OutputType; + readonly Directions = Object.values(EntitySearchDirection) as Array; + readonly PropagationDirectionTranslations = PropagationDirectionTranslations; + readonly minAllowedDeduplicationIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedDeduplicationIntervalInSecForCF; + readonly maxRelatedEntitiesToReturnPerCfArgument = getCurrentAuthState(this.store).maxRelatedEntitiesToReturnPerCfArgument; + + relatedAggregationConfiguration = this.fb.group({ + relation: this.fb.group({ + direction: [EntitySearchDirection.FROM, Validators.required], + relationType: ['Contains', Validators.required], + }), + arguments: this.fb.control({}, notEmptyObjectValidator()), + metrics: this.fb.control({}, notEmptyObjectValidator()), + deduplicationIntervalInSec: [this.minAllowedDeduplicationIntervalInSecForCF], + output: this.fb.control(defaultCalculatedFieldOutput), + useLatestTs: [false] + }); + + arguments$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => Object.keys(argumentsObj)) + ); + + argumentsEditorCompleter$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj ?? {})) + ); + + argumentsHighlightRules$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj)) + ); + + private readonly minAllowedScheduledUpdateIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedScheduledUpdateIntervalInSecForCF; + private propagateChange: (config: CalculatedFieldRelatedAggregationConfiguration) => void = () => { }; + + constructor(private fb: FormBuilder, + private store: Store) { + + this.relatedAggregationConfiguration.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((value: CalculatedFieldRelatedAggregationConfiguration) => { + this.updatedModel(value); + }) + } + + validate(): ValidationErrors | null { + return this.relatedAggregationConfiguration.valid || this.relatedAggregationConfiguration.disabled ? null : {invalidPropagateConfig: false}; + } + + writeValue(value: CalculatedFieldRelatedAggregationConfiguration): void { + this.relatedAggregationConfiguration.patchValue(value, {emitEvent: false}); + setTimeout(() => { + this.relatedAggregationConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); + }); + } + + registerOnChange(fn: (config: CalculatedFieldRelatedAggregationConfiguration) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_: any): void { } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.relatedAggregationConfiguration.disable({emitEvent: false}); + } else { + this.relatedAggregationConfiguration.enable({emitEvent: false}); + } + } + + fetchOptions(searchText: string): Observable> { + const search = searchText ? searchText?.toLowerCase() : ''; + return of(['Contains', 'Manages']).pipe(map(name => name?.filter(option => option.toLowerCase().includes(search)))); + } + + private updatedModel(value: CalculatedFieldRelatedAggregationConfiguration): void { + value.type = CalculatedFieldType.RELATED_ENTITIES_AGGREGATION; + value.scheduledUpdateInterval = this.minAllowedScheduledUpdateIntervalInSecForCF; + this.propagateChange(value); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module.ts new file mode 100644 index 0000000000..aaa7905845 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module.ts @@ -0,0 +1,49 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + CalculatedFieldOutputModule +} from '@home/components/calculated-fields/components/output/calculated-field-output.module'; +import { + CalculatedFieldArgumentsTableModule +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module'; +import { + RelatedEntitiesAggregationComponentComponent +} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component'; +import { + CalculatedFieldMetricsTableModule +} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-table.module'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CalculatedFieldOutputModule, + CalculatedFieldArgumentsTableModule, + CalculatedFieldMetricsTableModule, + ], + declarations: [ + RelatedEntitiesAggregationComponentComponent, + ], + exports: [ + RelatedEntitiesAggregationComponentComponent, + ] +}) +export class RelatedEntitiesAggregationComponentModule { +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html new file mode 100644 index 0000000000..161d2dcc25 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html @@ -0,0 +1,105 @@ + +
+
+
+ {{ 'calculated-fields.arguments' | translate }} +
+ +
+
+
+ {{ (isScript ? 'calculated-fields.type.script' : 'calculated-fields.expression') | translate }} +
+ + +
+
+ @if (simpleConfiguration.get('expressionSIMPLE').errors && simpleConfiguration.get('expressionSIMPLE').touched) { + + @if (simpleConfiguration.get('expressionSIMPLE').hasError('required')) { + {{ 'calculated-fields.hint.expression-required' | translate }} + } @else if (simpleConfiguration.get('expressionSIMPLE').hasError('pattern')) { + {{ 'calculated-fields.hint.expression-invalid' | translate }} + } @else if (simpleConfiguration.get('expressionSIMPLE').hasError('maxLength')) { + {{ 'calculated-fields.hint.expression-max-length' | translate }} + } + + } @else { + {{ 'calculated-fields.hint.expression' | translate }} + } +
+
+ +
{{ 'api-usage.tbel' | translate }} +
+ +
+
+ +
+
+
+ +
+ +
+ calculated-fields.use-latest-timestamp +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts new file mode 100644 index 0000000000..fd2e0783af --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts @@ -0,0 +1,209 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { booleanAttribute, Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { oneSpaceInsideRegex } from '@shared/models/regex.constants'; +import { + calculatedFieldDefaultScript, + CalculatedFieldScriptConfiguration, + CalculatedFieldSimpleConfiguration, + CalculatedFieldSimpleOutput, + CalculatedFieldType, + defaultSimpleCalculatedFieldOutput, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights, + OutputType +} from '@shared/models/calculated-field.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { deepClone } from '@core/utils'; +import { EntityId } from '@shared/models/id/entity-id'; +import { Observable } from 'rxjs'; +import { ScriptLanguage } from '@shared/models/rule-node.models'; +import { map } from 'rxjs/operators'; + +type SimpeConfiguration = CalculatedFieldSimpleConfiguration | CalculatedFieldScriptConfiguration; + +@Component({ + selector: 'tb-simple-configuration', + templateUrl: './simple-configuration.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SimpleConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => SimpleConfigurationComponent), + multi: true + } + ], +}) +export class SimpleConfigurationComponent implements ControlValueAccessor, Validator, OnChanges { + + @Input() + isScript: boolean; + + @Input({required: true}) + entityId: EntityId; + + @Input({required: true}) + tenantId: string; + + @Input({required: true}) + entityName: string; + + @Input({required: true}) + ownerId: EntityId; + + @Input({required: true}) + testScript: () => Observable; + + @Input({transform: booleanAttribute}) isEditValue = true; + + simpleConfiguration = this.fb.group({ + arguments: this.fb.control({}), + expressionSIMPLE: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], + expressionSCRIPT: [calculatedFieldDefaultScript], + output: this.fb.control(defaultSimpleCalculatedFieldOutput), + useLatestTs: [false] + }); + + readonly ScriptLanguage = ScriptLanguage; + readonly OutputType = OutputType; + + functionArgs$ = this.simpleConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)]) + ); + + argumentsEditorCompleter$ = this.simpleConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj ?? {})) + ); + + argumentsHighlightRules$ = this.simpleConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj)) + ); + + disabled = false; + + private propagateChange: (config: SimpeConfiguration) => void = () => { }; + + constructor(private fb: FormBuilder) { + this.simpleConfiguration.get('output').valueChanges.pipe( + takeUntilDestroyed(), + ).subscribe(() => { + this.toggleScopeByOutputType(); + }); + + this.simpleConfiguration.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((value) => { + const { expressionSIMPLE, expressionSCRIPT, ...config } = value; + const cfConfig = config as SimpeConfiguration; + cfConfig.expression = this.isScript ? expressionSCRIPT : expressionSIMPLE; + this.updatedModel(cfConfig); + }) + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (change.currentValue !== change.previousValue) { + if (propName === 'isScript' && !this.disabled) { + this.updatedFormWithScript(); + if (!change.firstChange) { + this.simpleConfiguration.updateValueAndValidity(); + } + } + } + } + } + + validate(): ValidationErrors | null { + return this.simpleConfiguration.valid || this.simpleConfiguration.disabled ? null : {invalidSimpleConfig: false}; + } + + writeValue(value: SimpeConfiguration): void { + const formValue: any = deepClone(value); + if (this.isScript) { + formValue.expressionSCRIPT = formValue.expression ?? calculatedFieldDefaultScript; + } else if (value.type === CalculatedFieldType.SIMPLE) { + formValue.expressionSIMPLE = formValue.expression; + } + this.simpleConfiguration.patchValue(formValue, {emitEvent: false}); + setTimeout(() => { + this.simpleConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); + this.simpleConfiguration.get('output').updateValueAndValidity({onlySelf: true}); + }); + } + + registerOnChange(fn: (config: SimpeConfiguration) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.simpleConfiguration.disable({emitEvent: false}); + } else { + this.simpleConfiguration.enable({emitEvent: false}); + this.updatedFormWithScript(); + } + } + + onTestScript() { + this.testScript().subscribe((expression) => { + this.simpleConfiguration.get('expressionSCRIPT').setValue(expression); + this.simpleConfiguration.get('expressionSCRIPT').markAsDirty(); + }) + } + + private updatedModel(value: SimpeConfiguration): void { + value.type = this.isScript ? CalculatedFieldType.SCRIPT : CalculatedFieldType.SIMPLE; + this.propagateChange(value); + } + + private updatedFormWithScript() { + if (this.isScript) { + this.simpleConfiguration.get('expressionSIMPLE').disable({emitEvent: false}); + this.simpleConfiguration.get('expressionSCRIPT').enable({emitEvent: false}); + } else { + this.simpleConfiguration.get('expressionSIMPLE').enable({emitEvent: false}); + this.simpleConfiguration.get('expressionSCRIPT').disable({emitEvent: false}); + } + this.toggleScopeByOutputType(); + } + + private toggleScopeByOutputType(): void { + if (this.isScript || this.simpleConfiguration.get('output').value.type === OutputType.Attribute) { + this.simpleConfiguration.get('useLatestTs').disable({emitEvent: false}); + } else { + this.simpleConfiguration.get('useLatestTs').enable({emitEvent: false}); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts new file mode 100644 index 0000000000..11c6899ae6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts @@ -0,0 +1,44 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + SimpleConfigurationComponent +} from '@home/components/calculated-fields/components/simple-configuration/simple-configuration.component'; +import { + CalculatedFieldOutputModule +} from '@home/components/calculated-fields/components/output/calculated-field-output.module'; +import { + CalculatedFieldArgumentsTableModule +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CalculatedFieldOutputModule, + CalculatedFieldArgumentsTableModule, + ], + declarations: [ + SimpleConfigurationComponent, + ], + exports: [ + SimpleConfigurationComponent + ] +}) +export class SimpleConfigurationModule {} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.html index f393a9130b..83b758a6d8 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.html @@ -1,6 +1,6 @@ + + + + + + + + + +
+ +
+ + + + +
+
+
+ +
+
+
calculated-fields.calculated-field-types
+ + +
+
+
entity.entity-type
+ + + {{ 'alarm-rule.any-type' | translate }} + + {{ entityTypeTranslations.get(type)?.type | translate }} + + + +
+ @if (cfFilterForm.get('entityType').value) { +
+
entity.entities
+ + +
+ } +
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.scss new file mode 100644 index 0000000000..beb6c4a343 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.scss @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + display: flex; + max-width: 100%; + .mdc-button { + max-width: 100%; + } +} + +:host ::ng-deep { + .mdc-button { + .mat-icon { + min-width: 24px; + } + .mdc-button__label { + overflow: hidden; + text-overflow: ellipsis; + } + } +} + +::ng-deep { + .tb-calculated-fields-config-component { + max-width: 100%; + width: 600px; + min-width: 100%; + flex: 1; + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.ts new file mode 100644 index 0000000000..333e74d195 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.ts @@ -0,0 +1,294 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + DestroyRef, + ElementRef, + forwardRef, + Inject, + InjectionToken, + Input, + OnInit, + Optional, + TemplateRef, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { TemplatePortal } from '@angular/cdk/portal'; +import { deepClone, isArraysEqualIgnoreUndefined, isDefinedAndNotNull, isEmpty, isUndefinedOrNull } from '@core/utils'; +import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { fromEvent, Subscription } from 'rxjs'; +import { POSITION_MAP } from '@shared/models/overlay.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + calculatedFieldsEntityTypeList, + CalculatedFieldsQuery, + calculatedFieldTypes, + CalculatedFieldTypeTranslations +} from '@shared/models/calculated-field.models'; +import { StringItemsOption } from '@shared/components/string-items-list.component'; +import { TranslateService } from '@ngx-translate/core'; + +export const CALCULATED_FIELDS_CONFIG_DATA = new InjectionToken('CalculatedFieldsFilterConfigData'); + +export interface CalculatedFieldsFilterConfigData { + panelMode: boolean; + userMode: boolean; + filterConfig: CalculatedFieldsQuery; + initialFilterConfig?: CalculatedFieldsQuery; +} + +@Component({ + selector: 'tb-calculated-fields-filter-config', + templateUrl: './calculated-fields-filter-config.component.html', + styleUrls: ['./calculated-fields-filter-config.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CalculatedFieldsFilterConfigComponent), + multi: true + } + ] +}) +export class CalculatedFieldsFilterConfigComponent implements OnInit, ControlValueAccessor { + + @ViewChild('calculatedFieldsFilterPanel') + calculatedFieldsFilterPanel: TemplateRef; + + @Input() + disabled: boolean; + + @coerceBoolean() + @Input() + buttonMode = true; + + @Input() + initialCfFilterConfig: CalculatedFieldsQuery = { + types: [], + entityType: null, + entities: [] + }; + + panelMode = false; + + buttonDisplayValue = this.translate.instant('calculated-fields.calculated-field-filter-title'); + + cfFilterForm: FormGroup; + + panelResult: CalculatedFieldsQuery = null; + + entityType = EntityType; + + listEntityTypes = calculatedFieldsEntityTypeList; + entityTypeTranslations = entityTypeTranslations; + + readonly types: StringItemsOption[] = calculatedFieldTypes.map(item => ({ + name: this.translate.instant(CalculatedFieldTypeTranslations.get(item).name), + value: item + })); + + private cfFilterOverlayRef: OverlayRef; + private cfFilterConfig: CalculatedFieldsQuery; + private resizeWindows: Subscription; + + private propagateChange = (_: any) => {}; + + constructor(@Optional() @Inject(CALCULATED_FIELDS_CONFIG_DATA) + private data: CalculatedFieldsFilterConfigData | undefined, + @Optional() private overlayRef: OverlayRef, + private fb: FormBuilder, + private overlay: Overlay, + private nativeElement: ElementRef, + private viewContainerRef: ViewContainerRef, + private destroyRef: DestroyRef, + private translate: TranslateService) { + } + + ngOnInit(): void { + if (this.data) { + this.panelMode = this.data.panelMode; + this.cfFilterConfig = this.data.filterConfig; + this.initialCfFilterConfig = this.data.initialFilterConfig; + if (this.panelMode && !this.initialCfFilterConfig) { + this.initialCfFilterConfig = deepClone(this.cfFilterConfig); + } + } + this.cfFilterForm = this.fb.group({ + types: [null, []], + entityType: [null, []], + entities: [null, []] + }); + this.cfFilterForm.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => { + if (!this.buttonMode) { + this.cfConfigUpdated(this.cfFilterForm.value); + } + } + ); + if (this.panelMode) { + this.updateCfConfigForm(this.cfFilterConfig); + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.cfFilterForm.disable({emitEvent: false}); + } else { + this.cfFilterForm.enable({emitEvent: false}); + } + } + + writeValue(cfFilterConfig?: CalculatedFieldsQuery): void { + this.cfFilterConfig = cfFilterConfig; + if (!this.initialCfFilterConfig && cfFilterConfig) { + this.initialCfFilterConfig = deepClone(cfFilterConfig); + } + this.updateButtonDisplayValue(); + this.updateCfConfigForm(cfFilterConfig); + } + + toggleCfFilterPanel($event: Event) { + if ($event) { + $event.stopPropagation(); + } + const config = new OverlayConfig({ + panelClass: 'tb-filter-panel', + backdropClass: 'cdk-overlay-transparent-backdrop', + hasBackdrop: true, + maxHeight: '80vh', + height: 'min-content', + minWidth: '' + }); + config.hasBackdrop = true; + config.positionStrategy = this.overlay.position() + .flexibleConnectedTo(this.nativeElement) + .withPositions([POSITION_MAP.bottomLeft]); + + this.cfFilterOverlayRef = this.overlay.create(config); + this.cfFilterOverlayRef.backdropClick().subscribe(() => { + this.cfFilterOverlayRef.dispose(); + }); + this.cfFilterOverlayRef.attach(new TemplatePortal(this.calculatedFieldsFilterPanel, + this.viewContainerRef)); + this.resizeWindows = fromEvent(window, 'resize').subscribe(() => { + this.cfFilterOverlayRef.updatePosition(); + }); + } + + cancel() { + this.updateCfConfigForm(this.cfFilterConfig); + this.cfFilterForm.markAsPristine(); + if (this.overlayRef) { + this.overlayRef.dispose(); + } else { + this.resizeWindows.unsubscribe(); + this.cfFilterOverlayRef.dispose(); + } + } + + update() { + this.cfConfigUpdated(this.cfFilterForm.value); + this.cfFilterForm.markAsPristine(); + if (this.panelMode) { + this.panelResult = this.cfFilterConfig; + } + if (this.overlayRef) { + this.overlayRef.dispose(); + } else { + this.resizeWindows.unsubscribe(); + this.cfFilterOverlayRef.dispose(); + } + } + + reset() { + const cfFilterConfig = this.cfFilterFromFormValue(this.cfFilterForm.value); + if (!this.cfFilterConfigEquals(cfFilterConfig, this.initialCfFilterConfig)) { + this.updateCfConfigForm(this.initialCfFilterConfig); + this.cfFilterForm.markAsDirty(); + } + } + + private cfFilterConfigEquals = (filter1?: CalculatedFieldsQuery, filter2?: CalculatedFieldsQuery): boolean => { + if (filter1 === filter2) { + return true; + } + if ((isUndefinedOrNull(filter1) || isEmpty(filter1)) && (isUndefinedOrNull(filter2) || isEmpty(filter2))) { + return true; + } else if (isDefinedAndNotNull(filter1) && isDefinedAndNotNull(filter2)) { + if (!isArraysEqualIgnoreUndefined(filter1.types, filter2.types)) { + return false; + } + if (!isArraysEqualIgnoreUndefined(filter1.entities, filter2.entities)) { + return false; + } + return filter1.entityType === filter2.entityType; + } + return false; + }; + + private updateCfConfigForm(cfFilterConfig?: CalculatedFieldsQuery) { + this.cfFilterForm.patchValue({ + types: cfFilterConfig?.types ?? [], + entityType: cfFilterConfig?.entityType ?? null, + entities: cfFilterConfig?.entities ?? [], + }, {emitEvent: false}); + } + + private cfConfigUpdated(formValue: any) { + this.cfFilterConfig = this.cfFilterFromFormValue(formValue); + this.updateButtonDisplayValue(); + this.propagateChange(this.cfFilterConfig); + } + + private updateButtonDisplayValue() { + if (this.buttonMode) { + const filterTextParts: string[] = []; + if (this.cfFilterConfig?.types?.length) { + filterTextParts.push(this.cfFilterConfig.types.map((type) => this.translate.instant(CalculatedFieldTypeTranslations.get(type).name)).join(', ')); + } + if (this.cfFilterConfig?.entityType) { + filterTextParts.push(this.translate.instant( entityTypeTranslations.get(this.cfFilterConfig.entityType).type)); + } + if (!filterTextParts.length) { + this.buttonDisplayValue = this.translate.instant('calculated-fields.calculated-field-filter-title'); + } else { + this.buttonDisplayValue = this.translate.instant('calculated-fields.filter-title') + `: ${filterTextParts.join(', ')}`; + } + } + } + + private cfFilterFromFormValue(formValue: any): CalculatedFieldsQuery { + return { + types: formValue?.types ?? [], + entityType: formValue?.entityType ?? null, + entities: formValue?.entities ?? [], + }; + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.html new file mode 100644 index 0000000000..f24c112f27 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.html @@ -0,0 +1,20 @@ + + + diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.scss new file mode 100644 index 0000000000..c6571e8450 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.scss @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + padding-right: 8px; + overflow: hidden; + max-width: 100%; +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.ts new file mode 100644 index 0000000000..b6bc46371c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.ts @@ -0,0 +1,43 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTableHeaderComponent } from '@home/components/entity/entity-table-header.component'; +import { CalculatedField, CalculatedFieldsQuery } from "@shared/models/calculated-field.models"; +import { CalculatedFieldsTableConfig } from '@home/components/calculated-fields/calculated-fields-table-config'; + +@Component({ + selector: 'tb-calculated-fields-table-header', + templateUrl: './calculated-fields-header.component.html', + styleUrls: ['./calculated-fields-header.component.scss'] +}) +export class CalculatedFieldsHeaderComponent extends EntityTableHeaderComponent { + + get calculatedFieldsTableConfig(): CalculatedFieldsTableConfig { + return this.entitiesTableConfig as CalculatedFieldsTableConfig; + } + + constructor(protected store: Store) { + super(store); + } + + calculatedFieldsFilterChanged(calculatedFieldFilterConfig: CalculatedFieldsQuery) { + this.calculatedFieldsTableConfig.calculatedFieldFilterConfig = calculatedFieldFilterConfig; + this.calculatedFieldsTableConfig.getTable().resetSortAndFilter(true, true); + } +} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.html index dacfa60d22..0e1b2a7e07 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.html @@ -1,6 +1,6 @@ - + @@ -72,6 +72,12 @@ device.lwm2m-security-config.client-public-key-hint + @@ -103,5 +109,11 @@
+ diff --git a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.scss b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.scss index 133d8e6014..cef379e280 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.scss +++ b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.ts b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.ts index ca91c14682..5c602f1189 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.ts +++ b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -14,20 +14,21 @@ /// limitations under the License. /// -import { Component, forwardRef, OnDestroy } from '@angular/core'; +import { Component, forwardRef, Input, OnDestroy } from '@angular/core'; import { ControlValueAccessor, - UntypedFormBuilder, - UntypedFormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, ValidationErrors, Validator, Validators } from '@angular/forms'; import { getDefaultClientSecurityConfig, - getDefaultServerSecurityConfig, Lwm2mClientKeyTooltipTranslationsMap, + getDefaultServerSecurityConfig, + Lwm2mClientKeyTooltipTranslationsMap, Lwm2mSecurityConfigModels, Lwm2mSecurityType, Lwm2mSecurityTypeTranslationMap @@ -35,6 +36,11 @@ import { import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { isDefinedAndNotNull } from '@core/utils'; +import { DeviceId } from "@shared/models/id/device-id"; +import { DeviceService } from "@core/http/device.service"; +import { ActionNotificationShow } from "@core/notification/notification.actions"; +import { Store } from "@ngrx/store"; +import { AppState } from "@core/core.state"; @Component({ selector: 'tb-device-credentials-lwm2m', @@ -65,7 +71,12 @@ export class DeviceCredentialsLwm2mComponent implements ControlValueAccessor, Va private destroy$ = new Subject(); private propagateChange = (v: any) => {}; - constructor(private fb: UntypedFormBuilder) { + @Input() + deviceId: DeviceId; + + constructor(protected store: Store, + private fb: UntypedFormBuilder, + private deviceService: DeviceService) { this.lwm2mConfigFormGroup = this.initLwm2mConfigForm(); } @@ -101,6 +112,41 @@ export class DeviceCredentialsLwm2mComponent implements ControlValueAccessor, Va this.destroy$.complete(); } + /** + * AbstractRpcController -> rpcController + * - API + * "/api/plugins/rpc/twoway/${this.deviceId.id}" + * - DiscoveryAll + * requestBody = "{\"method\":\"DiscoverAll\"}"; + * - "Registration Update Trigger", + * requestBody = "{\"method\": \"Execute\", \"params\": {\"id\": \"/1/0/8\"}} + * - "Bootstrap-Request Trigger" + * requestBody = "{\"method\": \"Execute\", \"params\": {\"id\": \"/1/0/9\"}} + */ + + public rebootDevice(isBootstrapServer: boolean): void { + this.deviceService.rebootDevice(this.deviceId.id, isBootstrapServer).subscribe(responseReboot => { + if (responseReboot.result === 'SUCCESS') { + this.store.dispatch(new ActionNotificationShow( + { + message: responseReboot.msg, + type: 'success', + duration: 1500, + verticalPosition: 'top', + horizontalPosition: 'left' + })); + } else { + this.store.dispatch(new ActionNotificationShow( + { + message: responseReboot.msg, + type: 'error', + verticalPosition: 'top', + horizontalPosition: 'left' + })); + } + }); + } + private initClientSecurityConfig(config: Lwm2mSecurityConfigModels): void { this.lwm2mConfigFormGroup.patchValue(config, {emitEvent: false}); this.securityConfigClientUpdateValidators(config.client.securityConfigClientMode); diff --git a/ui-ngx/src/app/modules/home/components/device/device-credentials-mqtt-basic.component.html b/ui-ngx/src/app/modules/home/components/device/device-credentials-mqtt-basic.component.html index 1c9f6e8dcb..f36eaa4565 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-credentials-mqtt-basic.component.html +++ b/ui-ngx/src/app/modules/home/components/device/device-credentials-mqtt-basic.component.html @@ -1,6 +1,6 @@ - - {{ 'device-profile.device-profile' | translate }} + + {{ 'device-profile.device-profile' | translate }} (); diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-dialog.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile-dialog.component.html index 80da175031..246912b170 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-dialog.component.html @@ -1,6 +1,6 @@ -
+
{{ 'tenant-profile.entities' | translate }} tenant-profile.unlimited @@ -26,10 +26,10 @@ - + {{ 'tenant-profile.maximum-devices-required' | translate}} - + {{ 'tenant-profile.maximum-devices-range' | translate}} @@ -39,10 +39,10 @@ - + {{ 'tenant-profile.maximum-dashboards-required' | translate}} - + {{ 'tenant-profile.maximum-dashboards-range' | translate}} @@ -54,10 +54,10 @@ - + {{ 'tenant-profile.maximum-assets-required' | translate}} - + {{ 'tenant-profile.maximum-assets-range' | translate}} @@ -67,10 +67,10 @@ - + {{ 'tenant-profile.maximum-users-required' | translate}} - + {{ 'tenant-profile.maximum-users-range' | translate}} @@ -89,10 +89,10 @@ - + {{ 'tenant-profile.maximum-customers-required' | translate}} - + {{ 'tenant-profile.maximum-customers-range' | translate}} @@ -102,10 +102,10 @@ - + {{ 'tenant-profile.maximum-rule-chains-required' | translate}} - + {{ 'tenant-profile.maximum-rule-chains-range' | translate}} @@ -117,10 +117,10 @@ - + {{ 'tenant-profile.maximum-edges-required' | translate }} - + {{ 'tenant-profile.maximum-edges-range' | translate }} @@ -141,10 +141,10 @@ - + {{ 'tenant-profile.max-r-e-executions-required' | translate}} - + {{ 'tenant-profile.max-r-e-executions-range' | translate}} @@ -154,10 +154,10 @@ - + {{ 'tenant-profile.max-transport-messages-required' | translate}} - + {{ 'tenant-profile.max-transport-messages-range' | translate}} @@ -176,10 +176,10 @@ - + {{ 'tenant-profile.max-j-s-executions-required' | translate}} - + {{ 'tenant-profile.max-j-s-executions-range' | translate}} @@ -189,10 +189,10 @@ - + {{ 'tenant-profile.max-tbel-executions-required' | translate}} - + {{ 'tenant-profile.max-tbel-executions-range' | translate}} @@ -204,10 +204,10 @@ - + {{ 'tenant-profile.max-rule-node-executions-per-message-required' | translate}} - + {{ 'tenant-profile.max-rule-node-executions-per-message-range' | translate}} @@ -217,10 +217,10 @@ - + {{ 'tenant-profile.max-transport-data-points-required' | translate}} - + {{ 'tenant-profile.max-transport-data-points-range' | translate}} @@ -239,10 +239,10 @@ - + {{ 'tenant-profile.max-calculated-fields-required' | translate}} - + {{ 'tenant-profile.max-calculated-fields-range' | translate}} @@ -252,10 +252,10 @@ - + {{ 'tenant-profile.max-data-points-per-rolling-arg-required' | translate}} - + {{ 'tenant-profile.max-data-points-per-rolling-arg-range' | translate}} @@ -267,10 +267,10 @@ - + {{ 'tenant-profile.max-arguments-per-cf-required' | translate}} - + {{ 'tenant-profile.max-arguments-per-cf-range' | translate}} @@ -290,10 +290,10 @@ - + {{ 'tenant-profile.max-state-size-required' | translate}} - + {{ 'tenant-profile.max-state-size-range' | translate}} @@ -303,15 +303,115 @@ - + {{ 'tenant-profile.max-value-argument-size-required' | translate}} - + {{ 'tenant-profile.max-value-argument-size-range' | translate}}
+
+ + 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}} + + + +
+
+ + tenant-profile.min-allowed-aggregation-interval + + + {{ 'tenant-profile.min-allowed-aggregation-interval-required' | translate}} + + + {{ 'tenant-profile.min-allowed-aggregation-interval-range' | translate}} + + + + + tenant-profile.min-allowed-deduplication-interval + + + {{ 'tenant-profile.min-allowed-deduplication-interval-required' | translate}} + + + {{ 'tenant-profile.min-allowed-deduplication-interval-range' | translate}} + + + +
+
+ + tenant-profile.intermediate-aggregation-interval + + + {{ 'tenant-profile.intermediate-aggregation-interval-required' | translate}} + + + {{ 'tenant-profile.intermediate-aggregation-interval-range' | translate}} + + + + + tenant-profile.reevaluation-check-interval + + + {{ 'tenant-profile.reevaluation-check-interval-required' | translate}} + + + {{ 'tenant-profile.reevaluation-check-interval-range' | translate}} + + + +
+
+ + tenant-profile.relation-search-entity-limit + + + {{ 'tenant-profile.relation-search-entity-limit-required' | translate}} + + + {{ 'tenant-profile.relation-search-entity-limit-range' | translate}} + + tenant-profile.relation-search-entity-limit-hint + +
+
@@ -326,10 +426,10 @@ - + {{ 'tenant-profile.max-d-p-storage-days-required' | translate}} - + {{ 'tenant-profile.max-d-p-storage-days-range' | translate}} @@ -339,10 +439,10 @@ - + {{ 'tenant-profile.alarms-ttl-days-required' | translate}} - + {{ 'tenant-profile.alarms-ttl-days-days-range' | translate}} @@ -354,10 +454,10 @@ - + {{ 'tenant-profile.default-storage-ttl-days-required' | translate}} - + {{ 'tenant-profile.default-storage-ttl-days-range' | translate}} @@ -367,10 +467,10 @@ - + {{ 'tenant-profile.rpc-ttl-days-required' | translate}} - + {{ 'tenant-profile.rpc-ttl-days-days-range' | translate}} @@ -382,10 +482,10 @@ - + {{ 'tenant-profile.queue-stats-ttl-days-required' | translate}} - + {{ 'tenant-profile.queue-stats-ttl-days-range' | translate}} @@ -395,10 +495,10 @@ - + {{ 'tenant-profile.rule-engine-exceptions-ttl-days-required' | translate}} - + {{ 'tenant-profile.rule-engine-exceptions-ttl-days-range' | translate}} @@ -413,16 +513,16 @@ {{ 'tenant-profile.sms-enabled' | translate }} - tenant-profile.max-sms - + {{ 'tenant-profile.max-sms-required' | translate}} - + {{ 'tenant-profile.max-sms-range' | translate}} @@ -432,10 +532,10 @@ - + {{ 'tenant-profile.max-emails-required' | translate}} - + {{ 'tenant-profile.max-emails-range' | translate}} @@ -445,15 +545,31 @@ - + {{ 'tenant-profile.max-created-alarms-required' | translate}} - + {{ 'tenant-profile.max-created-alarms-range' | translate}} +
+ + tenant-profile.alarms-reevaluation-interval + + + {{ 'tenant-profile.alarms-reevaluation-interval-required' | translate}} + + + {{ 'tenant-profile.alarms-reevaluation-interval-range' | translate}} + + + +
+
@@ -466,7 +582,7 @@ - + {{ 'tenant-profile.maximum-debug-duration-min-range' | translate }} @@ -485,10 +601,10 @@ - + {{ 'tenant-profile.maximum-resources-sum-data-size-required' | translate}} - + {{ 'tenant-profile.maximum-resources-sum-data-size-range' | translate}} @@ -498,10 +614,10 @@ - + {{ 'tenant-profile.maximum-ota-package-sum-data-size-required' | translate}} - + {{ 'tenant-profile.maximum-ota-package-sum-data-size-range' | translate}} @@ -513,10 +629,10 @@ - + {{ 'tenant-profile.maximum-resource-size-required' | translate}} - + {{ 'tenant-profile.maximum-resource-size-range' | translate}} @@ -533,14 +649,14 @@ tenant-profile.ws-limit-max-sessions-per-tenant - + {{ 'tenant-profile.too-small-value-zero' | translate}} tenant-profile.ws-limit-max-subscriptions-per-tenant - + {{ 'tenant-profile.too-small-value-zero' | translate}} @@ -549,14 +665,14 @@ tenant-profile.ws-limit-max-sessions-per-customer - + {{ 'tenant-profile.too-small-value-zero' | translate}} tenant-profile.ws-limit-max-subscriptions-per-customer - + {{ 'tenant-profile.too-small-value-zero' | translate}} @@ -572,14 +688,14 @@ tenant-profile.ws-limit-max-sessions-per-public-user - + {{ 'tenant-profile.too-small-value-zero' | translate}} tenant-profile.ws-limit-max-subscriptions-per-public-user - + {{ 'tenant-profile.too-small-value-zero' | translate}} @@ -588,14 +704,14 @@ tenant-profile.ws-limit-max-sessions-per-regular-user - + {{ 'tenant-profile.too-small-value-zero' | translate}} tenant-profile.ws-limit-max-subscriptions-per-regular-user - + {{ 'tenant-profile.too-small-value-zero' | translate}} @@ -604,7 +720,7 @@ tenant-profile.ws-limit-queue-per-session - + {{ 'tenant-profile.too-small-value-one' | translate}} diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.scss b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.scss index 0439320878..38ab6294ea 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.scss +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. 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..be674b78fc 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 @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -14,16 +14,13 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; -import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; -import { Store } from '@ngrx/store'; -import { AppState } from '@app/core/core.state'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; -import { DefaultTenantProfileConfiguration, TenantProfileConfiguration } from '@shared/models/tenant.model'; -import { isDefinedAndNotNull } from '@core/utils'; +import { Component, forwardRef, Input } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { DefaultTenantProfileConfiguration, FormControlsFrom } from '@shared/models/tenant.model'; +import { isDefinedAndNotNull, isUndefinedOrNull} from '@core/utils'; import { RateLimitsType } from './rate-limits/rate-limits.models'; -import { takeUntil } from 'rxjs/operators'; -import { Subject } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ selector: 'tb-default-tenant-profile-configuration', @@ -35,110 +32,112 @@ import { Subject } from 'rxjs'; multi: true }] }) -export class DefaultTenantProfileConfigurationComponent implements ControlValueAccessor, OnInit, OnDestroy { +export class DefaultTenantProfileConfigurationComponent implements ControlValueAccessor { - defaultTenantProfileConfigurationFormGroup: UntypedFormGroup; + tenantProfileConfigurationForm: FormGroup>; - private requiredValue: boolean; - private destroy$ = new Subject(); - get required(): boolean { - return this.requiredValue; - } @Input() - set required(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); - } + @coerceBoolean() + required: boolean; @Input() + @coerceBoolean() disabled: boolean; rateLimitsType = RateLimitsType; - private propagateChange = (v: any) => { }; - - constructor(private store: Store, - private fb: UntypedFormBuilder) { - this.defaultTenantProfileConfigurationFormGroup = this.fb.group({ - maxDevices: [null, [Validators.required, Validators.min(0)]], - maxAssets: [null, [Validators.required, Validators.min(0)]], - maxCustomers: [null, [Validators.required, Validators.min(0)]], - maxUsers: [null, [Validators.required, Validators.min(0)]], - maxDashboards: [null, [Validators.required, Validators.min(0)]], - maxRuleChains: [null, [Validators.required, Validators.min(0)]], - maxEdges: [null, [Validators.required, Validators.min(0)]], - maxResourcesInBytes: [null, [Validators.required, Validators.min(0)]], - maxOtaPackagesInBytes: [null, [Validators.required, Validators.min(0)]], - maxResourceSize: [null, [Validators.required, Validators.min(0)]], - transportTenantMsgRateLimit: [null, []], - transportTenantTelemetryMsgRateLimit: [null, []], - transportTenantTelemetryDataPointsRateLimit: [null, []], - transportDeviceMsgRateLimit: [null, []], - transportDeviceTelemetryMsgRateLimit: [null, []], - transportDeviceTelemetryDataPointsRateLimit: [null, []], - transportGatewayMsgRateLimit: [null, []], - transportGatewayTelemetryMsgRateLimit: [null, []], - transportGatewayTelemetryDataPointsRateLimit: [null, []], - transportGatewayDeviceMsgRateLimit: [null, []], - transportGatewayDeviceTelemetryMsgRateLimit: [null, []], - transportGatewayDeviceTelemetryDataPointsRateLimit: [null, []], - tenantEntityExportRateLimit: [null, []], - tenantEntityImportRateLimit: [null, []], - tenantNotificationRequestsRateLimit: [null, []], - tenantNotificationRequestsPerRuleRateLimit: [null, []], - maxTransportMessages: [null, [Validators.required, Validators.min(0)]], - maxTransportDataPoints: [null, [Validators.required, Validators.min(0)]], - maxREExecutions: [null, [Validators.required, Validators.min(0)]], - maxJSExecutions: [null, [Validators.required, Validators.min(0)]], - maxTbelExecutions: [null, [Validators.required, Validators.min(0)]], - maxDPStorageDays: [null, [Validators.required, Validators.min(0)]], - maxRuleNodeExecutionsPerMessage: [null, [Validators.required, Validators.min(0)]], - maxEmails: [null, [Validators.required, Validators.min(0)]], - maxSms: [null, []], - smsEnabled: [null, []], - maxCreatedAlarms: [null, [Validators.required, Validators.min(0)]], - maxDebugModeDurationMinutes: [null, [Validators.min(0)]], - defaultStorageTtlDays: [null, [Validators.required, Validators.min(0)]], - alarmsTtlDays: [null, [Validators.required, Validators.min(0)]], - rpcTtlDays: [null, [Validators.required, Validators.min(0)]], - queueStatsTtlDays: [null, [Validators.required, Validators.min(0)]], - ruleEngineExceptionsTtlDays: [null, [Validators.required, Validators.min(0)]], - tenantServerRestLimitsConfiguration: [null, []], - customerServerRestLimitsConfiguration: [null, []], - maxWsSessionsPerTenant: [null, [Validators.min(0)]], - maxWsSessionsPerCustomer: [null, [Validators.min(0)]], - maxWsSessionsPerRegularUser: [null, [Validators.min(0)]], - maxWsSessionsPerPublicUser: [null, [Validators.min(0)]], - wsMsgQueueLimitPerSession: [null, [Validators.min(0)]], - maxWsSubscriptionsPerTenant: [null, [Validators.min(0)]], - maxWsSubscriptionsPerCustomer: [null, [Validators.min(0)]], - maxWsSubscriptionsPerRegularUser: [null, [Validators.min(0)]], - maxWsSubscriptionsPerPublicUser: [null, [Validators.min(0)]], - wsUpdatesPerSessionRateLimit: [null, []], - cassandraWriteQueryTenantCoreRateLimits: [null, []], - cassandraReadQueryTenantCoreRateLimits: [null, []], - cassandraWriteQueryTenantRuleEngineRateLimits: [null, []], - cassandraReadQueryTenantRuleEngineRateLimits: [null, []], - edgeEventRateLimits: [null, []], - edgeEventRateLimitsPerEdge: [null, []], - edgeUplinkMessagesRateLimits: [null, []], - edgeUplinkMessagesRateLimitsPerEdge: [null, []], - maxCalculatedFieldsPerEntity: [null, [Validators.required, Validators.min(0)]], - maxArgumentsPerCF: [null, [Validators.required, Validators.min(0)]], - maxDataPointsPerRollingArg: [null, [Validators.required, Validators.min(0)]], - maxStateSizeInKBytes: [null, [Validators.required, Validators.min(0)]], - calculatedFieldDebugEventsRateLimit: [null, []], - maxSingleValueArgumentSizeInKBytes: [null, [Validators.required, Validators.min(0)]], + private propagateChange = (_v: any) => { }; + + constructor(private fb: FormBuilder) { + this.tenantProfileConfigurationForm = this.fb.group({ + maxDevices: [0, [Validators.required, Validators.min(0)]], + maxAssets: [0, [Validators.required, Validators.min(0)]], + maxCustomers: [0, [Validators.required, Validators.min(0)]], + maxUsers: [0, [Validators.required, Validators.min(0)]], + maxDashboards: [0, [Validators.required, Validators.min(0)]], + maxRuleChains: [0, [Validators.required, Validators.min(0)]], + maxEdges: [0, [Validators.required, Validators.min(0)]], + maxResourcesInBytes: [0, [Validators.required, Validators.min(0)]], + maxOtaPackagesInBytes: [0, [Validators.required, Validators.min(0)]], + maxResourceSize: [0, [Validators.required, Validators.min(0)]], + transportTenantMsgRateLimit: [''], + transportTenantTelemetryMsgRateLimit: [''], + transportTenantTelemetryDataPointsRateLimit: [''], + transportDeviceMsgRateLimit: [''], + transportDeviceTelemetryMsgRateLimit: [''], + transportDeviceTelemetryDataPointsRateLimit: [''], + transportGatewayMsgRateLimit: [''], + transportGatewayTelemetryMsgRateLimit: [''], + transportGatewayTelemetryDataPointsRateLimit: [''], + transportGatewayDeviceMsgRateLimit: [''], + transportGatewayDeviceTelemetryMsgRateLimit: [''], + transportGatewayDeviceTelemetryDataPointsRateLimit: [''], + tenantEntityExportRateLimit: [''], + tenantEntityImportRateLimit: [''], + tenantNotificationRequestsRateLimit: [''], + tenantNotificationRequestsPerRuleRateLimit: [''], + maxTransportMessages: [0, [Validators.required, Validators.min(0)]], + maxTransportDataPoints: [0, [Validators.required, Validators.min(0)]], + maxREExecutions: [0, [Validators.required, Validators.min(0)]], + maxJSExecutions: [0, [Validators.required, Validators.min(0)]], + maxTbelExecutions: [0, [Validators.required, Validators.min(0)]], + maxDPStorageDays: [0, [Validators.required, Validators.min(0)]], + maxRuleNodeExecutionsPerMessage: [0, [Validators.required, Validators.min(0)]], + maxEmails: [0, [Validators.required, Validators.min(0)]], + maxSms: [0], + smsEnabled: [false], + maxCreatedAlarms: [0, [Validators.required, Validators.min(0)]], + maxDebugModeDurationMinutes: [0, [Validators.min(0)]], + defaultStorageTtlDays: [0, [Validators.required, Validators.min(0)]], + alarmsTtlDays: [0, [Validators.required, Validators.min(0)]], + rpcTtlDays: [0, [Validators.required, Validators.min(0)]], + queueStatsTtlDays: [0, [Validators.required, Validators.min(0)]], + ruleEngineExceptionsTtlDays: [0, [Validators.required, Validators.min(0)]], + tenantServerRestLimitsConfiguration: [''], + customerServerRestLimitsConfiguration: [''], + maxWsSessionsPerTenant: [0, [Validators.min(0)]], + maxWsSessionsPerCustomer: [0, [Validators.min(0)]], + maxWsSessionsPerRegularUser: [0, [Validators.min(0)]], + maxWsSessionsPerPublicUser: [0, [Validators.min(0)]], + wsMsgQueueLimitPerSession: [0, [Validators.min(0)]], + maxWsSubscriptionsPerTenant: [0, [Validators.min(0)]], + maxWsSubscriptionsPerCustomer: [0, [Validators.min(0)]], + maxWsSubscriptionsPerRegularUser: [0, [Validators.min(0)]], + maxWsSubscriptionsPerPublicUser: [0, [Validators.min(0)]], + wsUpdatesPerSessionRateLimit: [''], + cassandraWriteQueryTenantCoreRateLimits: [''], + cassandraReadQueryTenantCoreRateLimits: [''], + cassandraWriteQueryTenantRuleEngineRateLimits: [''], + cassandraReadQueryTenantRuleEngineRateLimits: [''], + edgeEventRateLimits: [''], + edgeEventRateLimitsPerEdge: [''], + edgeUplinkMessagesRateLimits: [''], + edgeUplinkMessagesRateLimitsPerEdge: [''], + maxCalculatedFieldsPerEntity: [0, [Validators.required, Validators.min(0)]], + maxArgumentsPerCF: [0, [Validators.required, Validators.min(0)]], + maxRelationLevelPerCfArgument: [1, [Validators.required, Validators.min(1)]], + minAllowedDeduplicationIntervalInSecForCF: [0, [Validators.required, Validators.min(0)]], + minAllowedAggregationIntervalInSecForCF: [0, [Validators.required, Validators.min(0)]], + maxRelatedEntitiesToReturnPerCfArgument: [1, [Validators.required, Validators.min(1)]], + minAllowedScheduledUpdateIntervalInSecForCF: [0, [Validators.required, Validators.min(0)]], + intermediateAggregationIntervalInSecForCF: [0, [Validators.required, Validators.min(1)]], + cfReevaluationCheckInterval: [0, [Validators.required, Validators.min(1)]], + alarmsReevaluationInterval: [0, [Validators.required, Validators.min(1)]], + maxDataPointsPerRollingArg: [0, [Validators.required, Validators.min(0)]], + maxStateSizeInKBytes: [0, [Validators.required, Validators.min(0)]], + calculatedFieldDebugEventsRateLimit: [''], + maxSingleValueArgumentSizeInKBytes: [0, [Validators.required, Validators.min(0)]], }); - this.defaultTenantProfileConfigurationFormGroup.get('smsEnabled').valueChanges.pipe( - takeUntil(this.destroy$) + this.tenantProfileConfigurationForm.get('smsEnabled').valueChanges.pipe( + takeUntilDestroyed() ).subscribe((value: boolean) => { this.maxSmsValidation(value); } ); - this.defaultTenantProfileConfigurationFormGroup.valueChanges.pipe( - takeUntil(this.destroy$) + this.tenantProfileConfigurationForm.valueChanges.pipe( + takeUntilDestroyed() ).subscribe(() => { this.updateModel(); }); @@ -146,48 +145,43 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA private maxSmsValidation(smsEnabled: boolean) { if (smsEnabled) { - this.defaultTenantProfileConfigurationFormGroup.get('maxSms').addValidators([Validators.required, Validators.min(0)]); + this.tenantProfileConfigurationForm.get('maxSms').addValidators([Validators.required, Validators.min(0)]); } else { - this.defaultTenantProfileConfigurationFormGroup.get('maxSms').clearValidators(); + this.tenantProfileConfigurationForm.get('maxSms').clearValidators(); } - this.defaultTenantProfileConfigurationFormGroup.get('maxSms').updateValueAndValidity({emitEvent: false}); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); + this.tenantProfileConfigurationForm.get('maxSms').updateValueAndValidity({emitEvent: false}); } registerOnChange(fn: any): void { this.propagateChange = fn; } - registerOnTouched(fn: any): void { - } - - ngOnInit() { + registerOnTouched(_fn: any): void { } setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; if (this.disabled) { - this.defaultTenantProfileConfigurationFormGroup.disable({emitEvent: false}); + this.tenantProfileConfigurationForm.disable({emitEvent: false}); } else { - this.defaultTenantProfileConfigurationFormGroup.enable({emitEvent: false}); + this.tenantProfileConfigurationForm.enable({emitEvent: false}); } } writeValue(value: DefaultTenantProfileConfiguration | null): void { if (isDefinedAndNotNull(value)) { + if (isUndefinedOrNull(value.smsEnabled)) { + value.smsEnabled = true; + } this.maxSmsValidation(value.smsEnabled); - this.defaultTenantProfileConfigurationFormGroup.patchValue(value, {emitEvent: false}); + this.tenantProfileConfigurationForm.patchValue(value, {emitEvent: false}); } } private updateModel() { - let configuration: TenantProfileConfiguration = null; - if (this.defaultTenantProfileConfigurationFormGroup.valid) { - configuration = this.defaultTenantProfileConfigurationFormGroup.getRawValue(); + let configuration: DefaultTenantProfileConfiguration = null; + if (this.tenantProfileConfigurationForm.valid) { + configuration = this.tenantProfileConfigurationForm.getRawValue(); } this.propagateChange(configuration); } diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-details-dialog.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-details-dialog.component.html index 5b4851d169..5d7fc9876f 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-details-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-details-dialog.component.html @@ -1,6 +1,6 @@ -
- -

{{ '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-create.component.ts b/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.ts index aec1c10864..21ac0825c9 100644 --- a/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. 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..a5ced53c03 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 @@ -1,6 +1,6 @@ -
- -

{{ '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/complex-version-load.component.ts b/ui-ngx/src/app/modules/home/components/vc/complex-version-load.component.ts index 702115e314..4bf029404b 100644 --- a/ui-ngx/src/app/modules/home/components/vc/complex-version-load.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/complex-version-load.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. 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..4adeb47d69 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 @@ -1,6 +1,6 @@ -
+@if (!versionCreateResult$) {

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

+ [class.hidden]="(isLoading$ | async) === false">
- + version-control.version-name - + {{ 'version-control.version-name-required' | translate }} - + {{ 'version-control.export-credentials' | translate }} - + {{ 'version-control.export-attributes' | translate }} - + {{ 'version-control.export-relations' | translate }} - - {{ 'version-control.export-calculated-fields' | translate }} + + {{ (entityId.entityType === entityTypes.CUSTOMER ? 'version-control.export-alarm-rules' : 'version-control.export-calculated-fields') | translate }}
@@ -67,10 +68,9 @@ {{ 'action.create' | translate }}
-
-
-
-
+} @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-create.component.ts b/ui-ngx/src/app/modules/home/components/vc/entity-version-create.component.ts index 2042b7fdb6..b01134f85c 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-version-create.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/entity-version-create.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-version-diff.component.html b/ui-ngx/src/app/modules/home/components/vc/entity-version-diff.component.html index 72f4fb5096..941a2e42c1 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-version-diff.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/entity-version-diff.component.html @@ -1,6 +1,6 @@ -
- -

{{ '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 }} + + + {{ ( externalEntityId?.entityType === EntityType.CUSTOMER ? 'version-control.load-alarm-rules' : '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/entity-version-restore.component.ts b/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.ts index 4456eb7ce7..c48648dacf 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -33,6 +33,7 @@ import { delay, share } from 'rxjs/operators'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { Observable, Subscription } from 'rxjs'; import { parseHttpErrorMessage } from '@core/utils'; +import { EntityType } from '@shared/models/entity-type.models'; @Component({ selector: 'tb-entity-version-restore', @@ -62,6 +63,8 @@ export class EntityVersionRestoreComponent extends PageComponent implements OnIn errorMessage: SafeHtml; + EntityType = EntityType; + versionLoadResult$: Observable; private versionLoadResultSubscription: Subscription; diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html b/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html index e05fa4152c..cc3fb23da5 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html @@ -1,6 +1,6 @@ - +
- - - + , protected widgetConfigComponent: WidgetConfigComponent, private $injector: Injector, @@ -85,6 +88,8 @@ export class MapBasicConfigComponent extends BasicWidgetConfigComponent { this.mapWidgetConfigForm = this.fb.group({ mapSettings: [settings, []], + timewindowConfig: [getTimewindowConfig(configData.config), []], + showTitle: [configData.config.showTitle, []], title: [configData.config.title, []], titleFont: [configData.config.titleFont, []], @@ -104,15 +109,10 @@ export class MapBasicConfigComponent extends BasicWidgetConfigComponent { actions: [configData.config.actions || {}, []] }); - if (this.trip) { - this.mapWidgetConfigForm.addControl('timewindowConfig', this.fb.control(getTimewindowConfig(configData.config))) - } } protected prepareOutputConfig(config: any): WidgetConfigComponentData { - if (this.trip) { - setTimewindowConfig(this.widgetConfig.config, config.timewindowConfig); - } + setTimewindowConfig(this.widgetConfig.config, config.timewindowConfig); this.widgetConfig.config.settings = config.mapSettings || {}; this.widgetConfig.config.showTitle = config.showTitle; @@ -184,4 +184,20 @@ export class MapBasicConfigComponent extends BasicWidgetConfigComponent { config.enableFullscreen = buttons.includes('fullscreen'); } + public get displayTimewindowConfig(): boolean { + if (this.trip) { + return true; + } else { + return this.widget ? MapModelDefinition.hasTimewindow(this.widget) : false; + } + } + + public get onlyHistoryTimewindow(): boolean { + if (this.trip) { + return false; + } else { + return this.widget ? MapModelDefinition.datasourcesHasOnlyComparisonAggregation(this.widget) : false; + } + } + } diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html index 4dab1821cb..606f092199 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.html @@ -1,6 +1,6 @@ +
+
+ +
+
+ @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..b3ea52c2a8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.scss @@ -0,0 +1,135 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + .api-item-title { + font-weight: 500; + 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; + line-height: 20px; + font-weight: 400; + color: rgba(0, 0, 0, 0.54); + } + .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..26c3901e4b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts @@ -0,0 +1,167 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 = (this.isFiniteNumber(data[0][key.maxLimit.key]) && 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 = this.isFiniteNumber(data[0][key.maxLimit.key]) && data[0][key.maxLimit.key] !== 0 ? this.toShortNumber(data[0][key.maxLimit.key]) : '∞'; + key.current.value = this.isFiniteNumber(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; + } + + private isFiniteNumber(value: any): boolean { + return typeof value === 'number' && isFinite(value); + } + + 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/cards/label-card-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.html index f3f6a025da..c5084fd21a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.html @@ -1,6 +1,6 @@
- - {{ column.title }} + + {{'entity.show-all-columns' | translate}} + + @for (column of columns; track column.def) { + + {{ column.title }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.scss index 61c7399ab3..fb234637db 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts index 8c5f13de69..d9e94ee7bd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ /// import { Component, Inject, InjectionToken } from '@angular/core'; +import { isDefinedAndNotNull } from '@core/utils'; +import { SelectableColumnsPipe } from '@shared/public-api'; import { DisplayColumn } from '@home/components/widget/lib/table-widget.models'; export const DISPLAY_COLUMNS_PANEL_DATA = new InjectionToken('DisplayColumnsPanelData'); @@ -33,8 +35,28 @@ export class DisplayColumnsPanelComponent { columns: DisplayColumn[]; - constructor(@Inject(DISPLAY_COLUMNS_PANEL_DATA) public data: DisplayColumnsPanelData) { - this.columns = this.data.columns; + constructor(@Inject(DISPLAY_COLUMNS_PANEL_DATA) public data: DisplayColumnsPanelData, + private selectableColumnsPipe: SelectableColumnsPipe ) { + this.columns = this.selectableColumnsPipe.transform(this.data.columns); + } + + get allColumnsVisible(): boolean { + return isDefinedAndNotNull(this.columns) && this.columns.every(column => column.display); + } + + get someColumnsVisible(): boolean { + const filtredColumns = this.columns.filter(item => item.display); + return filtredColumns.length !== 0 && this.columns.length !== filtredColumns.length; + } + + public toggleAllColumns(event: any): void { + const isChecked = event.checked; + + this.columns.forEach(column => { + column.display = isChecked; + }); + + this.update(); } public update() { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/edges-overview-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/edges-overview-widget.component.html index ef07f37068..1f4d61c611 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/edges-overview-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/edges-overview-widget.component.html @@ -1,6 +1,6 @@ +
+
+ + + + + +
+
+ + + + + + +
+
+
+ +
+
+
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..8d295765f8 --- /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-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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..aed354bb69 --- /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-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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..9c516be2bd --- /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-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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..046d76d351 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.html @@ -0,0 +1,100 @@ + + +
+
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..6767ec1e7b --- /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-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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..b89679de5b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.ts @@ -0,0 +1,212 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, Validators +} 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, Validators.required], + 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, + hideDataKeyAggregation: 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/cards/dashboard-state-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/dashboard-state-widget-settings.component.html index b38fbb444f..3a8fb61d99 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/dashboard-state-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/dashboard-state-widget-settings.component.html @@ -1,6 +1,6 @@ +
+
+ {{ labelKey | translate}} +
+
+ + + + {{ ValueSourceTypeTranslation.get(type) | translate }} + + + + + +
+
+ + + + + + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/axis-scale-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/axis-scale-row.component.ts new file mode 100644 index 0000000000..88b01c19c5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/axis-scale-row.component.ts @@ -0,0 +1,209 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, UntypedFormControl, + UntypedFormGroup, ValidationErrors, Validator, + Validators, +} from '@angular/forms'; +import { + ValueSourceConfig, + ValueSourceType, + ValueSourceTypes, + ValueSourceTypeTranslation +} from '@shared/models/widget-settings.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { DataKey, Datasource, DatasourceType, } from '@shared/models/widget.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { IAliasController } from '@core/api/widget-api.models'; +import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; +import { merge } from 'rxjs'; + +@Component({ + selector: 'tb-axis-scale-row', + templateUrl: './axis-scale-row.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AxisScaleRowComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AxisScaleRowComponent), + multi: true + }, + ] +}) +export class AxisScaleRowComponent implements ControlValueAccessor, OnInit, Validator { + + @Input() + isPanelView = false; + + @Input() + aliasController: IAliasController; + + @Input() + callbacks: DataKeysCallbacks; + + @Input() + datasource: Datasource; + + @Input() + labelKey: string; + + ValueSourceType = ValueSourceType; + + DataKeyType = DataKeyType; + + DatasourceType = DatasourceType; + + ValueSourceTypeTranslation = ValueSourceTypeTranslation; + + ValueSourceTypes = ValueSourceTypes; + + limitForm: UntypedFormGroup; + + latestKeyFormControl: UntypedFormControl; + + entityKeyFormControl: UntypedFormControl; + + private propagateChanges: (value: any) => void = () => {}; + + private modelValue: ValueSourceConfig | null = null; + + constructor(private fb: UntypedFormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + this.limitForm = this.fb.group({ + type: [ValueSourceType.constant], + value: [null], + entityAlias: [null] + }); + this.latestKeyFormControl = this.fb.control(null, [Validators.required]); + this.entityKeyFormControl = this.fb.control(null, [Validators.required]); + merge( + this.latestKeyFormControl.valueChanges, + this.entityKeyFormControl.valueChanges, + this.limitForm.valueChanges + ).pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.updateValidators(); + this.updateModel(); + }); + } + + writeValue(value: ValueSourceConfig) { + this.modelValue = value; + this.limitForm.patchValue( + { + type: value.type || ValueSourceType.constant, + value: value.value, + entityAlias: value.entityAlias, + }, {emitEvent: false} + ); + if (value.type === ValueSourceType.latestKey) { + this.latestKeyFormControl.patchValue({ + type: value.latestKeyType, + name: value.latestKey + }, {emitEvent: false}); + } else if (value.type === ValueSourceType.entity) { + this.entityKeyFormControl.patchValue({ + type: value.entityKeyType, + name: value.entityKey + }, {emitEvent: false}); + } + + this.updateValidators(); + this.limitForm.markAllAsTouched(); + } + + registerOnChange(fn: any) { + this.propagateChanges = fn; + } + + registerOnTouched(fn: any) { + } + + validate(): ValidationErrors | null { + const type = this.limitForm.get('type')?.value; + const errors: any = {}; + + if (this.limitForm.invalid) { + errors.form = false; + } + + if (type === ValueSourceType.latestKey) { + if (!this.latestKeyFormControl.value || this.latestKeyFormControl.invalid) { + errors.latestKey = false; + } + } else if (type === ValueSourceType.entity) { + if (!this.limitForm.get('entityAlias')?.value) { + errors.entityAlias = false; + } + if (!this.entityKeyFormControl.value || this.entityKeyFormControl.invalid) { + errors.entityKey = false; + } + } + + return Object.keys(errors).length ? { axisLimitForm: errors } : null; + } + + private updateValidators() { + const axisTypeControl = this.limitForm.get('type'); + if (axisTypeControl && this.entityKeyFormControl && this.latestKeyFormControl) { + const type = axisTypeControl.value; + if (type === ValueSourceType.latestKey) { + this.latestKeyFormControl.setValidators([Validators.required]); + this.entityKeyFormControl.clearValidators(); + } else if (type === ValueSourceType.entity) { + this.latestKeyFormControl.clearValidators(); + this.limitForm.get('entityAlias').setValidators([Validators.required]); + this.entityKeyFormControl.setValidators([Validators.required]); + } else { + this.latestKeyFormControl.clearValidators(); + this.entityKeyFormControl.clearValidators(); + } + this.latestKeyFormControl.updateValueAndValidity({ emitEvent: false }); + this.entityKeyFormControl.updateValueAndValidity({ emitEvent: false }); + } + } + + private updateModel() { + const value = this.limitForm.value; + const type = value.type; + let updates: Partial = { type }; + if (type === ValueSourceType.latestKey) { + const latestKey: DataKey = this.latestKeyFormControl.value; + updates.latestKey = latestKey?.name; + updates.latestKeyType = latestKey?.type as any; + } else if (type === ValueSourceType.entity) { + const entityKey: DataKey = this.entityKeyFormControl.value; + updates.entityKey = entityKey?.name; + updates.entityKeyType = entityKey?.type as any; + updates.entityAlias = value?.entityAlias; + } else { + updates.value = value?.value; + } + this.modelValue = updates as ValueSourceConfig; + this.propagateChanges(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.html index ed3533a1f1..d0cffac423 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.html @@ -1,6 +1,6 @@ -
widgets.rpc.common-settings - - widgets.rpc.widget-title - - + widgets.rpc.button-label diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.ts index c4382d9da3..91dbf23e85 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -15,7 +15,7 @@ /// import { Component } from '@angular/core'; -import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { WidgetSettings, WidgetSettingsComponent, widgetTitleAutocompleteValues } from '@shared/models/widget.models'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -32,6 +32,8 @@ export class SendRpcWidgetSettingsComponent extends WidgetSettingsComponent { contentTypes = ContentType; + predefinedValues = widgetTitleAutocompleteValues; + constructor(protected store: Store, private fb: UntypedFormBuilder) { super(store); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html index 5069cffddb..330433141e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html @@ -1,6 +1,6 @@
- - widgets.gateway.gateway-title - - + {{ 'widgets.gateway.read-only' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-single-device-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-single-device-widget-settings.component.ts index a1fadd5bd1..60035457d1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-single-device-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-single-device-widget-settings.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -15,7 +15,7 @@ /// import { Component } from '@angular/core'; -import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { WidgetSettings, WidgetSettingsComponent, widgetTitleAutocompleteValues } from '@shared/models/widget.models'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -28,6 +28,8 @@ import { AppState } from '@core/core.state'; export class GatewayConfigSingleDeviceWidgetSettingsComponent extends WidgetSettingsComponent { gatewayConfigSingleDeviceWidgetSettingsForm: UntypedFormGroup; + + predefinedValues = widgetTitleAutocompleteValues; constructor(protected store: Store, private fb: UntypedFormBuilder) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.html index 3009c09cdc..c525c67b56 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.html @@ -1,6 +1,6 @@
- - widgets.gateway.events-title - - + widgets.gateway.events-filter diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-events-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-events-widget-settings.component.ts index 990c41ab03..ef7e8c7574 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-events-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-events-widget-settings.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -15,7 +15,7 @@ /// import { Component } from '@angular/core'; -import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { WidgetSettings, WidgetSettingsComponent, widgetTitleAutocompleteValues } from '@shared/models/widget.models'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -32,6 +32,8 @@ export class GatewayEventsWidgetSettingsComponent extends WidgetSettingsComponen separatorKeysCodes = [ENTER, COMMA, SEMICOLON]; gatewayEventsWidgetSettingsForm: UntypedFormGroup; + + predefinedValues = widgetTitleAutocompleteValues; constructor(protected store: Store, private fb: UntypedFormBuilder) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-logs-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-logs-settings.component.html index 7d0eb22536..5fa4e93576 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-logs-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-logs-settings.component.html @@ -1,6 +1,6 @@ -
-
- widgets.input-widgets.general-settings - - widgets.input-widgets.widget-title - - -
-
- widgets.input-widgets.image-settings -
- - widgets.input-widgets.image-format +
+
+
widgets.input-widgets.general-settings
+
+
widgets.input-widgets.widget-title
+ +
+
+
+
widgets.input-widgets.save-image
+ + {{ 'widgets.input-widgets.save-to-gallery' | translate }} + + @if (photoCameraInputWidgetSettingsForm.get('saveToGallery').value) { + + {{ 'widgets.input-widgets.public-image' | translate }} + + } +
+
+
widgets.input-widgets.image-settings
+
+
widgets.input-widgets.image-format
+ {{ 'widgets.input-widgets.image-format-jpeg' | translate }} @@ -40,20 +60,33 @@ - - widgets.input-widgets.image-quality - - -
-
- - widgets.input-widgets.max-image-width - - - - widgets.input-widgets.max-image-height - - -
-
+
+ + @if (photoCameraInputWidgetSettingsForm.get('imageFormat').value !== 'image/png') { +
+
widgets.input-widgets.image-quality
+ + + % + +
+ } + +
+
Size
+
+
widgets.input-widgets.max-image-width
+ + + px + + +
widgets.input-widgets.max-image-height
+ + + px + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.ts index 30c3e39833..097d09ad9b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -15,10 +15,10 @@ /// import { Component } from '@angular/core'; -import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; -import { Store } from '@ngrx/store'; +import { WidgetSettings, WidgetSettingsComponent, widgetTitleAutocompleteValues } from '@shared/models/widget.models'; import { AppState } from '@core/core.state'; +import { Store } from '@ngrx/store'; @Component({ selector: 'tb-photo-camera-input-widget-settings', @@ -29,6 +29,8 @@ export class PhotoCameraInputWidgetSettingsComponent extends WidgetSettingsCompo photoCameraInputWidgetSettingsForm: UntypedFormGroup; + predefinedValues = widgetTitleAutocompleteValues; + constructor(protected store: Store, private fb: UntypedFormBuilder) { super(store); @@ -42,6 +44,8 @@ export class PhotoCameraInputWidgetSettingsComponent extends WidgetSettingsCompo return { widgetTitle: '', + saveToGallery: false, + usePublicGalleryLink: true, imageFormat: 'image/png', imageQuality: 0.92, maxWidth: 640, @@ -57,11 +61,28 @@ export class PhotoCameraInputWidgetSettingsComponent extends WidgetSettingsCompo widgetTitle: [settings.widgetTitle, []], // Image settings - + saveToGallery: [settings.saveToGallery], + usePublicGalleryLink: [settings.usePublicGalleryLink], imageFormat: [settings.imageFormat, []], - imageQuality: [settings.imageQuality, [Validators.min(0), Validators.max(1)]], + imageQuality: [settings.imageQuality, [Validators.min(0), Validators.max(100)]], maxWidth: [settings.maxWidth, [Validators.min(1)]], maxHeight: [settings.maxHeight, [Validators.min(1)]] }); } + + protected prepareInputSettings(settings: WidgetSettings): WidgetSettings { + return { + ...settings, + saveToGallery: settings.saveToGallery ?? false, + usePublicGalleryLink: settings.usePublicGalleryLink ?? false, + imageQuality: settings.imageQuality * 100 + } + } + + protected prepareOutputSettings(settings: WidgetSettings): WidgetSettings { + return { + ...settings, + imageQuality: settings.imageQuality / 100 + } + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-attribute-general-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-attribute-general-settings.component.html index b2c7f1a24c..efa5b6cd3a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-attribute-general-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-attribute-general-settings.component.html @@ -1,6 +1,6 @@
-

{{ 'calculated-fields.debugging' | translate}}

+

{{ data.title | 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..d5ea2a9849 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/dashboard/import-dashboard-file-dialog.component.ts @@ -0,0 +1,89 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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/dashboard/make-dashboard-public-dialog.component.html b/ui-ngx/src/app/modules/home/pages/dashboard/make-dashboard-public-dialog.component.html index 50ea4c12b3..43f0f3dc6b 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/make-dashboard-public-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/dashboard/make-dashboard-public-dialog.component.html @@ -1,6 +1,6 @@ - -
- - device-profile.transport-type - - - {{deviceTransportTypeTranslations.get(type) | translate}} - - - - {{deviceTransportTypeHints.get(detailsForm.get('transportType').value) | translate}} - - - {{ 'device-profile.transport-type-required' | translate }} - - -
- - -
-
-
- - - - -
-
- +@if (entity) { + +
+ + device-profile.transport-type + + + {{deviceTransportTypeTranslations.get(type) | translate}} + + + + {{deviceTransportTypeHints.get(detailsForm.get('transportType').value) | translate}} + + + {{ 'device-profile.transport-type-required' | translate }} + + +
+ + +
-
- - -
-
- - + + @if (authUser.authority === authorities.TENANT_ADMIN && !isEdit) { + + + + } + + @if (hasOldRules || authUser.authority === authorities.TENANT_ADMIN && !isEdit) { + +
+ @if (hasOldRules && !isEdit) { +
+ + {{ 'alarm-rule.alarm-rules-actual' | translate }} + {{ 'alarm-rule.alarm-rules-old' | translate }} + +
+ } + @if (alarmRulesOldVersion || isEdit) { +
+
+ +
+
+ } @else { +
+ + +
+ } +
+
+ } + + +
+
+ + +
-
- + + @if (!isEdit) { + + + + } + @if (authUser.authority === authorities.TENANT_ADMIN && !isEdit) { + + + + + } +}
@@ -73,13 +111,3 @@
- - - - - - diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts index 1e4b735ff7..1aadb36778 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -41,6 +41,9 @@ export class DeviceProfileTabsComponent extends EntityTabsComponent, private destroyRef: DestroyRef) { super(store); @@ -57,6 +60,8 @@ export class DeviceProfileTabsComponent extends EntityTabsComponent - - - - - - - - - - - - - - - - - - - - - - - - - - +@if (entity) { + + + + + + + + + @if (authUser.authority === authorities.TENANT_ADMIN) { + + + + + + + } + + + + + + + + + + + + + @if (authUser.authority === authorities.TENANT_ADMIN) { + + + + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts index 7bd86922bf..01e186612b 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { DeviceInfo } from '@shared/models/device.models'; import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { EntityId } from "@shared/models/id/entity-id"; @Component({ selector: 'tb-device-tabs', @@ -27,6 +28,8 @@ import { EntityTabsComponent } from '../../components/entity/entity-tabs.compone }) export class DeviceTabsComponent extends EntityTabsComponent { + ownerId: EntityId; + constructor(protected store: Store) { super(store); } @@ -35,4 +38,9 @@ export class DeviceTabsComponent extends EntityTabsComponent { super.ngOnInit(); } + protected setEntity(entity: DeviceInfo) { + this.ownerId = entity.customerId.id !== this.nullUid ? entity.customerId : entity.tenantId; + super.setEntity(entity); + } + } diff --git a/ui-ngx/src/app/modules/home/pages/device/device.component.html b/ui-ngx/src/app/modules/home/pages/device/device.component.html index 0321554e8b..07606331cf 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device.component.html +++ b/ui-ngx/src/app/modules/home/pages/device/device.component.html @@ -1,6 +1,6 @@ - - - - - - - - - - - +@if (entity) { + + + + + + + + +} diff --git a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.ts index e0ca6e46c4..e034ad9b7f 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile.module.ts b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile.module.ts index f0e9d4454a..df9e721af5 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile.module.ts +++ b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile.module.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profiles-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profiles-table-config.resolver.ts index 27c1d06922..679294a466 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profiles-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profiles-table-config.resolver.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant-routing.module.ts b/ui-ngx/src/app/modules/home/pages/tenant/tenant-routing.module.ts index 734bb6f7c5..278974258a 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant/tenant-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant-routing.module.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.html b/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.html index b531e9bf87..c2485dc7f7 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.html @@ -1,6 +1,6 @@ - - - - - - - - - - - - - - - - - +@if (entity) { + + + + + + + + + + + + + + +} diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.ts index 70834e5a0c..39d31a5367 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html index 93055138f9..83d4e47d85 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html @@ -1,6 +1,6 @@ -
- - - - login.create-password - +
+ + + login.create-password - +
+ - - + +
-
-
- - - common.password - - lock - - - - login.password-again - - lock - - -
- - -
-
-
+ + common.password + + lock + + + {{ 'security.password-requirement.password-not-meet-requirements' | translate }} + + + + login.password-again + + lock + + + {{ 'security.password-requirement.new-passwords-not-match' | translate }} + + +
+ + +
+ + diff --git a/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts b/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts index 4cb8fa7cba..03103ae6c3 100644 --- a/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -14,61 +14,58 @@ /// limitations under the License. /// -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { AuthService } from '@core/auth/auth.service'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; -import { PageComponent } from '@shared/components/page.component'; -import { UntypedFormBuilder } from '@angular/forms'; -import { ActionNotificationShow } from '@core/notification/notification.actions'; -import { TranslateService } from '@ngx-translate/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; -import { Subscription } from 'rxjs'; +import { UserPasswordPolicy } from '@shared/models/settings.models'; +import { passwordsMatchValidator, passwordStrengthValidator } from '@shared/models/password.models'; +import { finalize } from 'rxjs/operators'; +import { PageComponent } from '@shared/components/page.component'; @Component({ selector: 'tb-create-password', templateUrl: './create-password.component.html', - styleUrls: ['./create-password.component.scss'] + styleUrls: ['./password.component.scss'] }) -export class CreatePasswordComponent extends PageComponent implements OnInit, OnDestroy { +export class CreatePasswordComponent extends PageComponent { - activateToken = ''; - sub: Subscription; + passwordPolicy: UserPasswordPolicy; + createPassword: FormGroup; - createPassword = this.fb.group({ - password: [''], - password2: [''] - }); + isLoading = false; - constructor(protected store: Store, - private route: ActivatedRoute, + private activateToken: string; + + constructor(private route: ActivatedRoute, private authService: AuthService, - private translate: TranslateService, - public fb: UntypedFormBuilder) { - super(store); - } + private fb: FormBuilder) { + super(); + this.activateToken = this.route.snapshot.queryParams['activateToken'] || ''; + this.passwordPolicy = this.route.snapshot.data['passwordPolicy']; - ngOnInit() { - this.sub = this.route - .queryParams - .subscribe(params => { - this.activateToken = params.activateToken || ''; - }); + this.buildCreatePasswordForm(); } - ngOnDestroy(): void { - super.ngOnDestroy(); - this.sub.unsubscribe(); + private buildCreatePasswordForm() { + this.createPassword = this.fb.group({ + newPassword: ['', [Validators.required, passwordStrengthValidator(this.passwordPolicy)]], + newPassword2: [''] + }, { + validators: [ + passwordsMatchValidator('newPassword', 'newPassword2'), + ] + }); } onCreatePassword() { - if (this.createPassword.get('password').value !== this.createPassword.get('password2').value) { - this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('login.passwords-mismatch-error'), - type: 'error' })); + if (this.createPassword.invalid) { + this.createPassword.markAllAsTouched(); } else { - this.authService.activate( - this.activateToken, - this.createPassword.get('password').value, true).subscribe(); + this.isLoading = true; + this.authService.activate(this.activateToken, this.createPassword.get('newPassword').value, true).pipe( + finalize(() => {this.isLoading = false;}) + ).subscribe(() => {}); } } } diff --git a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html new file mode 100644 index 0000000000..495d6fa0cb --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html @@ -0,0 +1,317 @@ + + + + + + + + + diff --git a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss new file mode 100644 index 0000000000..bd37554922 --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss @@ -0,0 +1,117 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../scss/constants'; + +:host { + display: flex; + flex: 1 1 0; + width: 100%; + height: 100%; + + .tb-two-factor-auth-login-content { + background-color: #eee; + --mdc-elevated-card-container-elevation: none; + + .progress-bar { + z-index: 10; + margin-bottom: -4px; + width: 450px; + } + + .tb-two-factor-auth-login-card { + max-height: 100vh; + overflow: auto; + padding: 48px 48px 48px 16px; + + @media #{$mat-xs} { + height: 100%; + } + + @media #{$mat-gt-xs} { + width: 450px !important; + } + + .mat-mdc-card-title { + font: 400 28px / 36px Roboto, "Helvetica Neue", sans-serif; + } + + .mat-mdc-card-header { + padding: 0; + } + + .mat-mdc-card-content { + margin-top: 34px; + margin-left: 40px; + padding: 0; + } + + .mat-body { + letter-spacing: 0.25px; + line-height: 16px; + } + + .backup-code { + p { + text-align: justify; + } + + .container { + border: 1px solid; + border-radius: 4px; + gap: 16px; + display: grid; + grid-template-columns: 1fr 1fr; + justify-items: center; + padding: 16px 0; + margin-bottom: 16px; + + .code { + letter-spacing: 0.25px; + font-family: Roboto Mono, "Helvetica Neue", monospace; + } + } + + .action-buttons { + margin-bottom: 40px; + } + } + } + } + + ::ng-deep { + .tb-two-factor-auth-login-content { + .tb-two-factor-auth-login-card { + button.mat-mdc-icon-button { + .mat-icon { + color: rgba(255, 255, 255, 0.8); + } + } + } + .mat-mdc-form-field .mat-mdc-form-field-hint-wrapper { + color: rgba(255, 255, 255, 0.8); + } + } + + button.provider, button.navigation { + text-align: start; + font-weight: 400; + color: rgba(255, 255, 255, 0.8); + &:not([disabled][disabled]) { + border-color: rgba(255, 255, 255, .8); + } + } + } +} diff --git a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts new file mode 100644 index 0000000000..c5e02c19da --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts @@ -0,0 +1,317 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, OnDestroy, OnInit, signal, ViewChild } from '@angular/core'; +import { AuthService } from '@core/auth/auth.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; +import { + AccountTwoFaSettings, + BackupCodeTwoFactorAuthAccountConfig, + TotpTwoFactorAuthAccountConfig, + TwoFactorAuthAccountConfig, + twoFactorAuthProvidersEnterCodeCardTranslate, + twoFactorAuthProvidersLoginData, + twoFactorAuthProvidersSuccessCardTranslate, + TwoFactorAuthProviderType +} from '@shared/models/two-factor-auth.models'; +import { phoneNumberPattern } from '@shared/models/settings.models'; +import { deepClone, isDefinedAndNotNull, unwrapModule } from '@core/utils'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import printTemplate from '@home/pages/security/authentication-dialog/backup-code-print-template.raw'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { mergeMap, tap } from 'rxjs/operators'; +import { ActionNotificationShow } from "@core/notification/notification.actions"; + +enum ForceTwoFAState { + SETUP = 'setup', + AUTHENTICATOR_APP = 'authenticatorApp', + SMS = 'sms', + EMAIL = 'email', + BACKUP_CODE = 'backupCode', +} + +enum ProvidersState { + INPUT = 'INPUT', + ENTER_CODE = 'ENTER_CODE', + SUCCESS = 'SUCCESS', +} + +enum BackupCodeState { + CODE = 'CODE', + SUCCESS = 'SUCCESS', +} + +@Component({ + selector: 'tb-force-two-factor-auth-login', + templateUrl: './force-two-factor-auth-login.component.html', + styleUrls: ['./force-two-factor-auth-login.component.scss'] +}) +export class ForceTwoFactorAuthLoginComponent extends PageComponent implements OnInit, OnDestroy { + + TwoFactorAuthProviderType = TwoFactorAuthProviderType; + providersData = twoFactorAuthProvidersLoginData; + allowProviders: TwoFactorAuthProviderType[] = []; + config: AccountTwoFaSettings; + + twoFactorAuthProvidersEnterCodeCardTranslate = twoFactorAuthProvidersEnterCodeCardTranslate; + twoFactorAuthProvidersSuccessCardTranslate = twoFactorAuthProvidersSuccessCardTranslate; + + ForceTwoFAState = ForceTwoFAState; + ProvidersState = ProvidersState; + BackupCodeState = BackupCodeState + + state = signal(ForceTwoFAState.SETUP); + appState = signal(ProvidersState.INPUT); + smsState = signal(ProvidersState.INPUT); + emailState = signal(ProvidersState.INPUT); + backupCodeState = signal(BackupCodeState.CODE); + + totpAuthURL: string; + totpAuthURLSecret: string; + backupCode: BackupCodeTwoFactorAuthAccountConfig; + + configForm: UntypedFormGroup; + smsConfigForm: UntypedFormGroup; + emailConfigForm: UntypedFormGroup; + + private providersInfo: TwoFactorAuthProviderType[]; + private authAccountConfig: TwoFactorAuthAccountConfig; + private useByDefault: boolean = true; + + @ViewChild('canvas', {static: false}) canvasRef: ElementRef; + + constructor(protected store: Store, + private authService: AuthService, + private twoFaService: TwoFactorAuthenticationService, + private importExportService: ImportExportService, + public dialog: MatDialog, + public dialogService: DialogService, + private fb: UntypedFormBuilder) { + super(store); + } + + ngOnInit() { + this.providersInfo = this.authService.forceTwoFactorAuthProviders; + this.allowedProviders(); + this.configForm = this.fb.group({ + verificationCode: ['', [ + Validators.required, + Validators.minLength(6), + Validators.maxLength(6), + Validators.pattern(/^\d*$/) + ]] + }); + + this.smsConfigForm = this.fb.group({ + phone: ['', [Validators.required, Validators.pattern(phoneNumberPattern)]] + }); + + this.emailConfigForm = this.fb.group({ + email: [getCurrentAuthUser(this.store).sub, [Validators.required, Validators.email]] + }); + + this.twoFaService.getAccountTwoFaSettings().subscribe(accountConfig => { + if (accountConfig) { + this.config = accountConfig; + this.useByDefault = false; + } + }); + } + + goBackByType(type: TwoFactorAuthProviderType) { + switch (type) { + case TwoFactorAuthProviderType.TOTP: + this.appState.set(ProvidersState.INPUT); + this.updateQRCode(); + break; + case TwoFactorAuthProviderType.SMS: + this.smsState.set(ProvidersState.INPUT); + break; + case TwoFactorAuthProviderType.EMAIL: + this.emailState.set(ProvidersState.INPUT); + break; + } + } + + get isAnyProviderAvailable() { + return this.config?.configs ? Object.keys(this.config?.configs)?.length < this.allowProviders?.length : true; + } + + private allowedProviders() { + if (isDefinedAndNotNull(this.config)) { + this.allowProviders = this.providersInfo; + } else { + this.allowProviders = this.providersInfo.filter(provider => provider !== TwoFactorAuthProviderType.BACKUP_CODE); + } + } + + updateState(type: TwoFactorAuthProviderType) { + switch (type) { + case TwoFactorAuthProviderType.TOTP: + this.state.set(ForceTwoFAState.AUTHENTICATOR_APP); + this.twoFaService.generateTwoFaAccountConfig(TwoFactorAuthProviderType.TOTP).subscribe(accountConfig => { + this.authAccountConfig = accountConfig as TotpTwoFactorAuthAccountConfig; + this.totpAuthURL = this.authAccountConfig.authUrl; + this.totpAuthURLSecret = new URL(this.totpAuthURL).searchParams.get('secret'); + this.authAccountConfig.useByDefault = this.useByDefault; + this.useByDefault = false; + this.updateQRCode(); + }); + break; + case TwoFactorAuthProviderType.SMS: + this.state.set(ForceTwoFAState.SMS); + break; + case TwoFactorAuthProviderType.EMAIL: + this.state.set(ForceTwoFAState.EMAIL); + break; + case TwoFactorAuthProviderType.BACKUP_CODE: + this.state.set(ForceTwoFAState.BACKUP_CODE); + this.twoFaService.generateTwoFaAccountConfig(TwoFactorAuthProviderType.BACKUP_CODE).pipe( + tap((data: BackupCodeTwoFactorAuthAccountConfig) => this.backupCode = data), + mergeMap(data => this.twoFaService.verifyAndSaveTwoFaAccountConfig(data, null, {ignoreLoading: true})) + ).subscribe((config) => { + this.config = config; + }); + break; + } + } + + sendSmsCode() { + if (this.smsConfigForm.valid) { + this.authAccountConfig = { + providerType: TwoFactorAuthProviderType.SMS, + useByDefault: this.useByDefault, + phoneNumber: this.smsConfigForm.get('phone').value as string + }; + this.useByDefault = false; + this.twoFaService.submitTwoFaAccountConfig(this.authAccountConfig).subscribe(() => this.smsState.set(ProvidersState.ENTER_CODE)); + } + } + + sendEmailCode() { + if (this.emailConfigForm.valid) { + this.authAccountConfig = { + providerType: TwoFactorAuthProviderType.EMAIL, + useByDefault: this.useByDefault, + email: this.emailConfigForm.get('email').value as string + }; + this.useByDefault = false; + this.twoFaService.submitTwoFaAccountConfig(this.authAccountConfig).subscribe(() => this.emailState.set(ProvidersState.ENTER_CODE)); + } + } + + tryAnotherWay(type: TwoFactorAuthProviderType) { + this.state.set(ForceTwoFAState.SETUP); + this.configForm.reset(); + switch (type) { + case TwoFactorAuthProviderType.TOTP: + this.appState.set(ProvidersState.INPUT); + break; + case TwoFactorAuthProviderType.SMS: + this.smsState.set(ProvidersState.INPUT); + this.smsConfigForm.reset(); + break; + case TwoFactorAuthProviderType.EMAIL: + this.emailState.set(ProvidersState.INPUT) + this.emailConfigForm.get('email').reset(getCurrentAuthUser(this.store).sub); + break; + } + } + + saveConfig(type: TwoFactorAuthProviderType) { + if (this.configForm.valid) { + this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, + this.configForm.get('verificationCode').value).subscribe({ + next: (config) => { + switch (type) { + case TwoFactorAuthProviderType.TOTP: + this.appState.set(ProvidersState.SUCCESS); + break; + case TwoFactorAuthProviderType.SMS: + this.smsState.set(ProvidersState.SUCCESS); + break; + case TwoFactorAuthProviderType.EMAIL: + this.emailState.set(ProvidersState.SUCCESS); + break; + } + this.config = config; + this.authAccountConfig = null; + this.allowedProviders(); + }, + error: error => { + if (error.status === 400) { + this.configForm.get('verificationCode').setErrors({incorrectCode: true}); + } else if (error.status === 429) { + this.configForm.get('verificationCode').setErrors({tooManyRequest: true}); + } else { + this.store.dispatch(new ActionNotificationShow({ + message: error.error.message, + type: 'error', + verticalPosition: 'top', + horizontalPosition: 'left' + })); + } + } + }) + } + } + + private updateQRCode() { + import('qrcode').then((QRCode) => { + unwrapModule(QRCode).toCanvas(this.canvasRef.nativeElement, this.totpAuthURL); + this.canvasRef.nativeElement.style.width = 'auto'; + this.canvasRef.nativeElement.style.height = 'auto'; + }); + } + + ngOnDestroy() { + super.ngOnDestroy(); + } + + cancelLogin() { + this.authService.logout(); + } + + downloadFile() { + this.importExportService.exportText(this.backupCode.codes, 'backup-codes'); + } + + printCode() { + const codeTemplate = deepClone(this.backupCode.codes) + .map(code => `
${code}
`).join(''); + const printPage = printTemplate.replace('${codesBlock}', codeTemplate); + const newWindow = window.open('', 'Print backup code'); + + newWindow.document.open(); + newWindow.document.write(printPage); + + setTimeout(() => { + newWindow.print(); + + newWindow.document.close(); + + setTimeout(() => { + newWindow.close(); + }, 10); + }, 0); + } +} diff --git a/ui-ngx/src/app/modules/login/pages/login/link-expired.component.html b/ui-ngx/src/app/modules/login/pages/login/link-expired.component.html index e9dcd74b9c..15eb084094 100644 --- a/ui-ngx/src/app/modules/login/pages/login/link-expired.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/link-expired.component.html @@ -1,6 +1,6 @@
diff --git a/ui-ngx/src/app/modules/login/pages/login/login.component.scss b/ui-ngx/src/app/modules/login/pages/login/login.component.scss index 2784335d7b..bf39ab93aa 100644 --- a/ui-ngx/src/app/modules/login/pages/login/login.component.scss +++ b/ui-ngx/src/app/modules/login/pages/login/login.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,45 +23,49 @@ margin-top: 36px; margin-bottom: 76px; background-color: #eee; + --mdc-elevated-card-container-elevation: none; .tb-login-form { @media #{$mat-gt-xs} { - width: 550px !important; + width: 480px !important; } .forgot-password { - padding: 0 0.5em 1em; - .tb-reset-password { - padding: 0 6px; - } + --mat-text-button-horizontal-padding: 0; + --mdc-text-button-container-height: 20px; } .tb-action-button{ - padding: 20px 0 16px; + margin: 20px 0 16px; } } - .oauth-container{ - padding: 0; - - .container-divider { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - width: 100%; + .container-divider { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + width: 100%; + margin-bottom: 16px; - .line { - flex: 1; - } + .mat-divider-horizontal{ + --mat-divider-color: var(--mdc-outlined-text-field-outline-color); + } - .mat-divider-horizontal{ - position: relative; - } + .text { + padding-right: 8px; + padding-left: 8px; + font-size: 16px; + line-height: 24px; + } + } - .text { - padding-right: 10px; - padding-left: 10px; - } + .oauth-container{ + .title { + font-size: 20px; + line-height: 24px; + letter-spacing: .1px; + text-align: center; + margin-bottom: 4px; } .material-icons{ @@ -71,15 +75,33 @@ a.login-with-button { color: rgba(black, 0.87); - background-color: map-get($tb-dark-theme-background, raised-button); + background-color: #ffffff; + } + + .login-button-container { + a.login-with-button { + max-width: 170px; + min-width: 60px; + flex-grow: 1; + flex-basis: 130px; + + .tb-mat-20 { + margin-right: 12px; + } + } - &:hover { - border-bottom: 0; + &:has(> :nth-child(1):last-child) { + a.login-with-button { + max-width: 100%; + } } - .icon { - height: 20px; - width: 20px; + &:has(> :nth-child(2):last-child), + &:has(> :nth-child(4):last-child) { + a.login-with-button { + max-width: 100%; + flex-basis: 180px; + } } } } diff --git a/ui-ngx/src/app/modules/login/pages/login/login.component.ts b/ui-ngx/src/app/modules/login/pages/login/login.component.ts index a4bd2853b9..e49309298d 100644 --- a/ui-ngx/src/app/modules/login/pages/login/login.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/login.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -16,15 +16,14 @@ import { Component, OnInit } from '@angular/core'; import { AuthService } from '@core/auth/auth.service'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; -import { PageComponent } from '@shared/components/page.component'; import { UntypedFormBuilder, Validators } from '@angular/forms'; import { HttpErrorResponse } from '@angular/common/http'; import { Constants } from '@shared/models/constants'; import { Router } from '@angular/router'; import { OAuth2ClientLoginInfo } from '@shared/models/oauth2.models'; import { validateEmail } from '@app/core/utils'; +import { PageComponent } from '@shared/components/page.component'; +import { finalize } from 'rxjs/operators'; @Component({ selector: 'tb-login', @@ -34,6 +33,7 @@ import { validateEmail } from '@app/core/utils'; export class LoginComponent extends PageComponent implements OnInit { passwordViolation = false; + isLoading = false; loginFormGroup = this.fb.group({ username: ['', [Validators.required, validateEmail]], @@ -41,11 +41,10 @@ export class LoginComponent extends PageComponent implements OnInit { }); oauth2Clients: Array = null; - constructor(protected store: Store, - private authService: AuthService, + constructor(private authService: AuthService, public fb: UntypedFormBuilder, private router: Router) { - super(store); + super(); } ngOnInit() { @@ -54,9 +53,11 @@ export class LoginComponent extends PageComponent implements OnInit { login(): void { if (this.loginFormGroup.valid) { - this.authService.login(this.loginFormGroup.value).subscribe( - () => {}, - (error: HttpErrorResponse) => { + this.isLoading = true; + this.authService.login(this.loginFormGroup.value).pipe( + finalize(() => {this.isLoading = false;}) + ).subscribe({ + error: (error: HttpErrorResponse) => { if (error && error.error && error.error.errorCode) { if (error.error.errorCode === Constants.serverErrorCode.credentialsExpired) { this.router.navigateByUrl(`login/resetExpiredPassword?resetToken=${error.error.resetToken}`); @@ -65,12 +66,9 @@ export class LoginComponent extends PageComponent implements OnInit { } } } - ); - } else { - Object.keys(this.loginFormGroup.controls).forEach(field => { - const control = this.loginFormGroup.get(field); - control.markAsTouched({onlySelf: true}); }); + } else { + this.loginFormGroup.markAllAsTouched(); } } diff --git a/ui-ngx/src/app/modules/login/pages/login/create-password.component.scss b/ui-ngx/src/app/modules/login/pages/login/password.component.scss similarity index 83% rename from ui-ngx/src/app/modules/login/pages/login/create-password.component.scss rename to ui-ngx/src/app/modules/login/pages/login/password.component.scss index 6d5d687d13..34d93f5d38 100644 --- a/ui-ngx/src/app/modules/login/pages/login/create-password.component.scss +++ b/ui-ngx/src/app/modules/login/pages/login/password.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,9 +18,10 @@ :host { display: flex; flex: 1 1 0; - .tb-create-password-content { + .tb-password-content { background-color: #eee; - .tb-create-password-card { + --mdc-elevated-card-container-elevation: none; + .tb-password-card { @media #{$mat-gt-xs} { width: 450px !important; } diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html index abf2e6c61c..bbf159107f 100644 --- a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html @@ -1,6 +1,6 @@ -
- - - - login.request-password-reset - + + + login.request-password-reset +
- +
-
-
- - - login.email - - email - - {{ 'user.invalid-email-format' | translate }} - - -
- - -
-
-
+ + login.email + + email + + {{ 'user.invalid-email-format' | translate }} + + +
+ + +
diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts index 3daeaa693d..b1513105a1 100644 --- a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -27,9 +27,9 @@ import { validateEmail } from '@app/core/utils'; @Component({ selector: 'tb-reset-password-request', templateUrl: './reset-password-request.component.html', - styleUrls: ['./reset-password-request.component.scss'] + styleUrls: ['./password.component.scss'] }) -export class ResetPasswordRequestComponent extends PageComponent implements OnInit { +export class ResetPasswordRequestComponent extends PageComponent { clicked: boolean = false; @@ -44,9 +44,6 @@ export class ResetPasswordRequestComponent extends PageComponent implements OnIn super(store); } - ngOnInit() { - } - disableInputs() { this.requestPasswordRequest.disable(); this.clicked = true; diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html index 5233189902..481fd33df6 100644 --- a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html @@ -1,6 +1,6 @@ -
- - - - login.password-reset - - -
login.expired-password-reset-message
+
+ + + login.password-reset + + login.expired-password-reset-message - +
+ - - + +
-
-
- - - login.new-password - - lock - - - - login.new-password-again - - lock - - -
- - -
-
-
+ + login.new-password + + lock + + + {{ 'security.password-requirement.password-not-meet-requirements' | translate }} + + + + login.new-password-again + + lock + + + {{ 'security.password-requirement.new-passwords-not-match' | translate }} + + +
+ + +
+ + diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts index 202722cf15..23e775f085 100644 --- a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -14,67 +14,60 @@ /// limitations under the License. /// -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { AuthService } from '@core/auth/auth.service'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; -import { PageComponent } from '@shared/components/page.component'; -import { UntypedFormBuilder } from '@angular/forms'; -import { ActionNotificationShow } from '@core/notification/notification.actions'; -import { TranslateService } from '@ngx-translate/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { Subscription } from 'rxjs'; +import { UserPasswordPolicy } from '@shared/models/settings.models'; +import { passwordsMatchValidator, passwordStrengthValidator } from '@shared/models/password.models'; +import { finalize } from 'rxjs/operators'; @Component({ selector: 'tb-reset-password', templateUrl: './reset-password.component.html', - styleUrls: ['./reset-password.component.scss'] + styleUrls: ['./password.component.scss'] }) -export class ResetPasswordComponent extends PageComponent implements OnInit, OnDestroy { +export class ResetPasswordComponent { isExpiredPassword: boolean; + isLoading = false; - resetToken = ''; - sub: Subscription; + resetPassword: FormGroup; + passwordPolicy: UserPasswordPolicy; - resetPassword = this.fb.group({ - newPassword: [''], - newPassword2: [''] - }); + private resetToken: string; - constructor(protected store: Store, - private route: ActivatedRoute, + constructor(private route: ActivatedRoute, private router: Router, private authService: AuthService, - private translate: TranslateService, - public fb: UntypedFormBuilder) { - super(store); - } + private fb: FormBuilder) { + + this.resetToken = this.route.snapshot.queryParams['resetToken'] || ''; + this.passwordPolicy = this.route.snapshot.data['passwordPolicy']; + this.isExpiredPassword = this.route.snapshot.data['expiredPassword'] ?? false; - ngOnInit() { - this.isExpiredPassword = this.route.snapshot.data.expiredPassword; - this.sub = this.route - .queryParams - .subscribe(params => { - this.resetToken = params.resetToken || ''; - }); + this.buildResetPasswordForm(); } - ngOnDestroy(): void { - super.ngOnDestroy(); - this.sub.unsubscribe(); + private buildResetPasswordForm() { + this.resetPassword = this.fb.group({ + newPassword: ['', [Validators.required, passwordStrengthValidator(this.passwordPolicy)]], + newPassword2: [''] + }, { + validators: [ + passwordsMatchValidator('newPassword', 'newPassword2'), + ] + }); } onResetPassword() { - if (this.resetPassword.get('newPassword').value !== this.resetPassword.get('newPassword2').value) { - this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('login.passwords-mismatch-error'), - type: 'error' })); + if (this.resetPassword.invalid) { + this.resetPassword.markAllAsTouched(); } else { - this.authService.resetPassword( - this.resetToken, - this.resetPassword.get('newPassword').value).subscribe( - () => this.router.navigateByUrl('login') - ); + this.isLoading = true; + this.authService.resetPassword(this.resetToken, this.resetPassword.get('newPassword').value).pipe( + finalize(() => {this.isLoading = false}) + ).subscribe(() => this.router.navigateByUrl('login')); } } } diff --git a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html index 5175c01546..f1a58933fc 100644 --- a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html @@ -1,6 +1,6 @@ - + {{ labelText }} + @if (countryFormGroup.get('country').value?.flag) { + + {{ countryFormGroup.get('country').value.flag }} + + } +
+
{{ 'entity.limit-reached' | translate }}
+
+
+
+
+ + +
+
+ {{ 'entity.request-sysadmin-text' | translate }} + {{ 'entity.login-here' | translate }} + {{ 'entity.to-increase-limit' | translate }} +
+
diff --git a/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.scss b/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.scss new file mode 100644 index 0000000000..c888b0ac2c --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.scss @@ -0,0 +1,97 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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'; + +.entity-limit-exceeded-dialog { + max-width: 100%; + height: 100%; + + @media #{$mat-gt-xs} { + max-width: 500px; + max-height: 80vh; + } + + position: relative; + padding: 40px 40px 24px 40px; + display: flex; + flex-direction: column; + gap: 20px; + + .close-btn { + position: absolute; + top: 8px; + right: 8px; + color: rgba(0, 0, 0, 0.54); + } + + .entity-limit-exceeded-bg { + position: relative; + width: 100%; + height: 140px; + &:before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: $tb-primary-color; + mask-image: url(/assets/entity-limit-reached.svg); + -webkit-mask-image: url(/assets/entity-limit-reached.svg); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-size: contain; + mask-position: center; + -webkit-mask-position: center; + } + } + + .entity-limit-exceeded-title { + font-size: 24px; + font-style: normal; + font-weight: 500; + line-height: 32px; + color: rgba(0, 0, 0, 0.87); + text-align: center; + } + + .entity-limit-exceeded-container { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + justify-content: center; + .entity-limit-exceeded-text { + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 24px; + color: rgba(0, 0, 0, 0.76); + text-align: center; + } + } + + .request-entity-limit-increase-sysadmin-prompt { + text-align: center; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + color: rgba(0, 0, 0, 0.54); + } + +} diff --git a/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.ts new file mode 100644 index 0000000000..ccbc3174d7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.ts @@ -0,0 +1,94 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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 { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { Component, Inject, ViewEncapsulation } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { TranslateService } from '@ngx-translate/core'; +import { DialogService } from '@core/services/dialog.service'; +import { AuthService } from '@core/auth/auth.service'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { NotificationService } from '@core/http/notification.service'; + +export interface EntityLimitExceededDialogData { + entityType: EntityType; + limit: number; +} + +// @dynamic +@Component({ + selector: 'tb-entity-limit-exceeded-dialog', + templateUrl: './entity-limit-exceeded-dialog.component.html', + styleUrls: ['./entity-limit-exceeded-dialog.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class EntityLimitExceededDialogComponent extends DialogComponent { + + limitReachedText: string; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: EntityLimitExceededDialogData, + public dialogRef: MatDialogRef, + private authService: AuthService, + private dialogs: DialogService, + private translate: TranslateService, + private notificationService: NotificationService) { + super(store, router, dialogRef); + + let entitiesText: string; + if (data.limit > 1) { + entitiesText = data.limit + ' ' + (this.translate.instant(entityTypeTranslations.get(data.entityType).typePlural) as string).toLowerCase(); + } else { + entitiesText = '1 ' + (this.translate.instant(entityTypeTranslations.get(data.entityType).type) as string).toLowerCase(); + } + this.limitReachedText = this.translate.instant('entity.limit-reached-text', { entities: entitiesText, entity: (this.translate.instant(entityTypeTranslations.get(data.entityType).type) as string).toLowerCase() }); + } + + cancel(): void { + this.dialogRef.close(); + } + + requestLimitIncrease($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.notificationService.sendEntitiesLimitIncreaseRequest(this.data.entityType).subscribe( + () => { + this.dialogRef.close(); + this.dialogs.alert( + this.translate.instant('entity.increase-limit-request-sent-title'), + this.translate.instant('entity.increase-limit-request-sent-text'), + this.translate.instant('action.close') + ); + } + ); + } + + loginAsSysAdmin($event: Event) { + if ($event) { + $event.preventDefault(); + $event.stopPropagation(); + } + this.authService.redirectUrl = `/tenants/${getCurrentAuthUser(this.store).tenantId}`; + this.authService.logout(); + } + +} diff --git a/ui-ngx/src/app/shared/components/dialog/error-alert-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/error-alert-dialog.component.html index c9caa08f66..379a7266db 100644 --- a/ui-ngx/src/app/shared/components/dialog/error-alert-dialog.component.html +++ b/ui-ngx/src/app/shared/components/dialog/error-alert-dialog.component.html @@ -1,6 +1,6 @@ - - @if (keyControl.value) { + @if (keyControl.value && keyControl.enabled) { - } @else if (keyControl.hasError('required') && keyControl.touched) { + } @else if (keyControl.hasError('required') && keyControl.touched && keyControl.enabled) { warning @@ -43,7 +43,7 @@ @for (key of filteredKeys$ | async; track key) { } @empty { - @if (!this.keyControl.value) { + @if (!this.keyControl.value && enableAutocomplete) { {{ 'entity.no-keys-found' | translate }} } } 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..6aef1d513d 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 @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -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,10 @@ 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'); + @Input() enableAutocomplete = true; + entityFilter = input.required(); dataKeyType = input.required(); keyScopeType = input(); @@ -67,12 +82,18 @@ export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Val keys$ = this.keyInputSubject.asObservable() .pipe( switchMap(() => { + if (!this.enableAutocomplete) { + return of([] as string[]); + } return this.cachedResult ? of(this.cachedResult) : this.entityService.findEntityKeysByQuery({ pageLink: { page: 0, pageSize: 100 }, entityFilter: this.entityFilter(), - }, this.dataKeyType() === DataKeyType.attribute, this.dataKeyType() === DataKeyType.timeseries, this.keyScopeType()); + }, this.dataKeyType() === DataKeyType.attribute, this.dataKeyType() === DataKeyType.timeseries, this.keyScopeType(), {ignoreLoading: true}); }), map(result => { + if (Array.isArray(result)) { + return result; + } this.cachedResult = result; switch (this.dataKeyType()) { case DataKeyType.attribute: @@ -96,6 +117,7 @@ export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Val constructor( private fb: FormBuilder, private entityService: EntityService, + private translate: TranslateService, ) { this.keyControl.valueChanges .pipe(takeUntilDestroyed()) @@ -118,6 +140,7 @@ export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Val if (filterChanged || keyScopeChanged || keyTypeChanged) { this.keyControl.setValue('', {emitEvent: false}); + this.cachedResult = null; } } @@ -136,10 +159,18 @@ export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Val registerOnTouched(_): void {} validate(): ValidationErrors | null { - return this.keyControl.valid ? null : { keyControl: false }; + return this.keyControl.valid || this.keyControl.disabled ? null : { keyControl: false }; } writeValue(value: string): void { this.keyControl.patchValue(value, {emitEvent: false}); } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.keyControl.disable({emitEvent: false}); + } else { + this.keyControl.enable({emitEvent: false}); + } + } } diff --git a/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html index 7d1c2e192c..f48bf48804 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html @@ -1,6 +1,6 @@ {{ label }} diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.scss b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.scss index 6d6fb0ce7d..9a4f70fe06 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.scss +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts index f8a4f49a82..53e1d59cc1 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -21,7 +21,7 @@ import { AliasEntityType, EntityType, entityTypeTranslations } from '@app/shared import { EntityService } from '@core/http/entity.service'; import { coerceBoolean } from '@shared/decorators/coercion'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatFormFieldAppearance } from '@angular/material/form-field'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; @Component({ selector: 'tb-entity-type-select', @@ -52,8 +52,16 @@ export class EntityTypeSelectComponent implements ControlValueAccessor, OnInit, @coerceBoolean() showLabel: boolean; + private labelValue = this.translate.instant('entity.type'); + + get label(): string { + return this.labelValue; + } + @Input() - label = this.translate.instant('entity.type'); + set label(value: string) { + this.labelValue = value ?? this.translate.instant('entity.type'); + } @Input() @coerceBoolean() @@ -68,6 +76,9 @@ export class EntityTypeSelectComponent implements ControlValueAccessor, OnInit, @Input() appearance: MatFormFieldAppearance = 'fill'; + @Input() + subscriptSizing: SubscriptSizing = 'fixed'; + @Input() @coerceBoolean() inlineField: boolean; @@ -170,4 +181,8 @@ export class EntityTypeSelectComponent implements ControlValueAccessor, OnInit, return ''; } } + + markAsTouched(): void { + this.entityTypeFormGroup.get('entityType').markAsTouched(); + } } diff --git a/ui-ngx/src/app/shared/components/fab-toolbar.component.html b/ui-ngx/src/app/shared/components/fab-toolbar.component.html index 598b5b0b57..3dc6688557 100644 --- a/ui-ngx/src/app/shared/components/fab-toolbar.component.html +++ b/ui-ngx/src/app/shared/components/fab-toolbar.component.html @@ -1,6 +1,6 @@ - +@if (isExternal) { + + + +} @else { + + + +} + + + Logo + + diff --git a/ui-ngx/src/app/shared/components/logo.component.scss b/ui-ngx/src/app/shared/components/logo.component.scss index d0e259d6d2..1ce10f8f2d 100644 --- a/ui-ngx/src/app/shared/components/logo.component.scss +++ b/ui-ngx/src/app/shared/components/logo.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,12 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +:host { + pointer-events: auto; + a { + border: none; + img { + width: 100%; + height: 100%; + } + } +} + :host-context(.login-logo) { - img.tb-logo-title { - width: 280px; - height: 60px; + width: 280px; + height: 60px; + a { text-decoration: none; - cursor: pointer; border: none; transform: none; diff --git a/ui-ngx/src/app/shared/components/logo.component.ts b/ui-ngx/src/app/shared/components/logo.component.ts index 7f58d8a4f0..1a91c03751 100644 --- a/ui-ngx/src/app/shared/components/logo.component.ts +++ b/ui-ngx/src/app/shared/components/logo.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -14,19 +14,43 @@ /// limitations under the License. /// -import { Component } from '@angular/core'; +import { Component, Input, OnInit } from '@angular/core'; +import { AuthService } from '@core/auth/auth.service'; +import { AppState } from '@core/core.state'; +import { Store } from '@ngrx/store'; +import { UrlTree } from '@angular/router'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { UrlHolder } from '@shared/pipe/image.pipe'; @Component({ selector: 'tb-logo', templateUrl: './logo.component.html', styleUrls: ['./logo.component.scss'] }) -export class LogoComponent { +export class LogoComponent implements OnInit { - logo = 'assets/logo_title_white.svg'; + @Input() + src: string | UrlHolder = 'assets/logo_title_white.svg'; - gotoThingsboard(): void { - window.open('https://thingsboard.io', '_blank'); + @Input() + link: string | UrlTree; + + @Input() + target: string = null; + + isExternal = false; + + constructor(private authService: AuthService, + private store: Store) { } + ngOnInit() { + if (!this.link) { + const authState = getCurrentAuthState(this.store); + this.link = this.authService.defaultUrl(true, authState); + } + if (typeof this.link === 'string' && this.link.startsWith('http')) { + this.isExternal = true; + } + } } diff --git a/ui-ngx/src/app/shared/components/markdown-editor.component.html b/ui-ngx/src/app/shared/components/markdown-editor.component.html index 8dda248312..f3ee58451f 100644 --- a/ui-ngx/src/app/shared/components/markdown-editor.component.html +++ b/ui-ngx/src/app/shared/components/markdown-editor.component.html @@ -1,6 +1,6 @@
-
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.scss b/ui-ngx/src/app/shared/components/material-icons.component.scss index 3ab0cfc993..0282ccd790 100644 --- a/ui-ngx/src/app/shared/components/material-icons.component.scss +++ b/ui-ngx/src/app/shared/components/material-icons.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 The Thingsboard Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. 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..8556bdbad4 100644 --- a/ui-ngx/src/app/shared/components/material-icons.component.ts +++ b/ui-ngx/src/app/shared/components/material-icons.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -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/shared/components/message-type-autocomplete.component.html b/ui-ngx/src/app/shared/components/message-type-autocomplete.component.html index 42660c3c91..b073d93a4e 100644 --- a/ui-ngx/src/app/shared/components/message-type-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/message-type-autocomplete.component.html @@ -1,6 +1,6 @@ + +
+ @for (rule of passwordErrorRules; track $index) { + @if (!rule.policyProp || passwordPolicy[rule.policyProp] > 0) { +

+ + {{ checkForError(rule.key) ? 'mdi:close' : 'mdi:check' }} + + {{ rule.translation | translate : passwordPolicy }} +

+ } + } +
+
+
diff --git a/ui-ngx/src/app/shared/components/password-requirements-tooltip.component.scss b/ui-ngx/src/app/shared/components/password-requirements-tooltip.component.scss new file mode 100644 index 0000000000..a0bd527632 --- /dev/null +++ b/ui-ngx/src/app/shared/components/password-requirements-tooltip.component.scss @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.password-checklist-card { + background-color: #0000009E; + backdrop-filter: blur(8px); + color: white; + padding: 12px 16px; + border-radius: 8px; + position: relative; + min-width: 220px; + display: flex; + gap: 8px; + flex-direction: column; + + & > tb-icon { + color: white; + } + + & > p { + margin: 0; + } + + & > .tooltip-arrow { + position: absolute; + bottom: -6px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid #002b36; + } +} diff --git a/ui-ngx/src/app/shared/components/password-requirements-tooltip.component.ts b/ui-ngx/src/app/shared/components/password-requirements-tooltip.component.ts new file mode 100644 index 0000000000..391ebf1d69 --- /dev/null +++ b/ui-ngx/src/app/shared/components/password-requirements-tooltip.component.ts @@ -0,0 +1,53 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Input, ViewEncapsulation } from '@angular/core'; +import { CdkOverlayOrigin, ConnectionPositionPair } from '@angular/cdk/overlay'; +import { passwordErrorRules } from '@shared/models/password.models'; +import { AbstractControl } from '@angular/forms'; +import { UserPasswordPolicy } from '@shared/models/settings.models'; +import { POSITION_MAP } from '@shared/models/overlay.models'; + +@Component({ + selector: 'tb-password-requirements-tooltip', + templateUrl: './password-requirements-tooltip.component.html', + styleUrl: './password-requirements-tooltip.component.scss', + encapsulation: ViewEncapsulation.None +}) +export class PasswordRequirementsTooltipComponent { + @Input() passwordControl: AbstractControl; + @Input() passwordPolicy: UserPasswordPolicy; + @Input() trigger: CdkOverlayOrigin; + + passwordErrorRules = passwordErrorRules; + isTooltipOpen = false; + + overlayPositions: ConnectionPositionPair[] = [ + {...POSITION_MAP.top, offsetY: -20} + ]; + + checkForError(errorName: string): boolean { + return this.passwordControl?.hasError(errorName) ?? false; + } + + onFocus(): void { + this.isTooltipOpen = true; + } + + onBlur(): void { + this.isTooltipOpen = false; + } +} diff --git a/ui-ngx/src/app/shared/components/phone-input.component.html b/ui-ngx/src/app/shared/components/phone-input.component.html index e10800483b..663576bd70 100644 --- a/ui-ngx/src/app/shared/components/phone-input.component.html +++ b/ui-ngx/src/app/shared/components/phone-input.component.html @@ -1,6 +1,6 @@ + + {{label}} +

+  
+  @if (predefinedValuesButton) {
+    
+  }
+  
+    warning
+  
+  
+    {{errorText}}
+  
+
+ +
+ @for(option of filteredOptions; track option; let i = $index) { + + + + } +
+
diff --git a/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.scss b/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.scss new file mode 100644 index 0000000000..aa68113205 --- /dev/null +++ b/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.scss @@ -0,0 +1,98 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 "constants"; + +:host { + .highlighted-text { + position: absolute; + inset: 0; + margin: 0; + z-index: 1; + pointer-events: none; + padding-top: var(--mat-form-field-filled-with-label-container-padding-top); + padding-bottom: var(--mat-form-field-filled-with-label-container-padding-bottom); + font: inherit; + letter-spacing: inherit; + text-decoration: inherit; + text-transform: inherit; + overflow: hidden; + color: var(--mdc-filled-text-field-input-text-color, var(--mat-app-on-surface)); + + &.disabled { + color: var(--mdc-filled-text-field-disabled-input-text-color); + } + } + + .input-field-highlight { + position: relative; + background: transparent; + z-index: 2; + color: transparent; + width: 100%; + } +} + +:host ::ng-deep { + + .mdc-text-field--outlined .mat-mdc-form-field-infix { + .highlighted-text { + padding-top: var(--mat-form-field-container-vertical-padding); + padding-bottom: var(--mat-form-field-container-vertical-padding); + } + } + + .highlighted-text { + .highlight { + color: $tb-secondary-color; + text-shadow: 0 0 0.5px var($tb-secondary-color, var(--mat-app-on-surface)); + } + + &.disabled { + .highlight { + color: $tb-primary-color-light; + text-shadow: 0 0 0.5px var($tb-primary-color-light, var(--mdc-filled-text-field-disabled-input-text-color)); + } + } + } +} + +::ng-deep { + div.tb-sting-pattern-autocomplete-panel { + width: 100%; + max-height: 256px; + visibility: visible; + transform-origin: center top; + overflow: auto; + padding: 8px 0; + box-sizing: border-box; + position: static; + border-radius: var(--mat-autocomplete-container-shape, var(--mat-app-corner-extra-small)); + box-shadow: var(--mat-autocomplete-container-elevation-shadow); + background-color: var(--mat-autocomplete-background-color, var(--mat-app-surface-container)) + } + + .cdk-overlay-pane:not(.mat-mdc-autocomplete-panel-above) & { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + .mat-mdc-autocomplete-panel-above & { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + transform-origin: center bottom; + } + +} diff --git a/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.ts b/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.ts new file mode 100644 index 0000000000..17bce4b9d7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.ts @@ -0,0 +1,434 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT 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, + ElementRef, + forwardRef, + Input, + OnChanges, + OnInit, + QueryList, + SimpleChanges, + TemplateRef, + ViewChild, + ViewChildren, + ViewContainerRef +} from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormControl, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { fromEvent, merge } from 'rxjs'; +import { debounceTime, tap } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + ConnectedPosition, + FlexibleConnectedPositionStrategy, + Overlay, + OverlayRef, + PositionStrategy +} from '@angular/cdk/overlay'; +import { TemplatePortal } from '@angular/cdk/portal'; + +@Component({ + selector: 'tb-string-pattern-autocomplete', + templateUrl: './string-pattern-autocomplete.component.html', + styleUrls: ['./string-pattern-autocomplete.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => StringPatternAutocompleteComponent), + multi: true + } + ] +}) +export class StringPatternAutocompleteComponent implements ControlValueAccessor, OnInit, OnChanges { + + @ViewChild('inputRef', {static: true}) inputRef: ElementRef; + @ViewChild('highlightTextRef', {static: true}) highlightTextRef: ElementRef; + @ViewChild('autocompleteTemplate', {static: true}) autocompleteTemplate: TemplateRef; + @ViewChildren('optionItem', { read: ElementRef }) optionItems!: QueryList>; + + @Input() + disabled: boolean; + + @Input() + @coerceBoolean() + required = false; + + @Input({required: true}) + predefinedValues: Array; + + @Input() + placeholderText: string = this.translate.instant('widget-config.set'); + + @Input() + subscriptSizing: SubscriptSizing = 'fixed'; + + @Input() + additionalClass: string | string[] | Record; + + @Input() + appearance: MatFormFieldAppearance = 'fill'; + + @Input() + label: string; + + @Input() + tooltipClass = 'tb-error-tooltip'; + + @Input() + errorText: string; + + @Input() + @coerceBoolean() + showInlineError = false; + + @Input() + @coerceBoolean() + predefinedValuesButton = false; + + @Input() + patternSymbol = '$'; + + @Input() + brackets: 'curly' | 'square'; + + selectionFormControl: FormControl; + filteredOptions: Array; + + searchText = ''; + highlightedHtml = ''; + activeOptionIndex = -1; + + private modelValue: string | null; + private overlayRef!: OverlayRef; + + private predefinedValuesButtonMode = false; + + private propagateChange = (_val: any) => { + }; + + constructor(private fb: FormBuilder, + private overlay: Overlay, + private translate: TranslateService, + private viewContainerRef: ViewContainerRef, + private destroyRef: DestroyRef, + private cd: ChangeDetectorRef) { + } + + ngOnInit() { + this.selectionFormControl = this.fb.control('', this.required ? [Validators.required] : []); + merge( + fromEvent(this.inputRef.nativeElement, 'selectionchange'), + this.selectionFormControl.valueChanges.pipe(tap(value => this.updateView(value))) + ).pipe( + debounceTime(50), + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.onSelectionChange(); + this.cd.markForCheck(); + }); + + fromEvent(this.inputRef.nativeElement, 'keydown') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(event => this.handleKeydown(event)); + } + + ngOnChanges(changes: SimpleChanges) { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (propName === 'predefinedValues' || propName === 'patternSymbol') { + this.highlightedHtml = this.getHighlightHtml(this.modelValue); + } + if (propName === 'required') { + if (change.currentValue) { + this.selectionFormControl.addValidators(Validators.required); + } else { + this.selectionFormControl.removeValidators(Validators.required); + } + this.selectionFormControl.updateValueAndValidity({emitEvent: false}) + } + } + } + } + + writeValue(option?: string): void { + this.searchText = ''; + this.modelValue = option ?? null; + this.highlightedHtml = this.getHighlightHtml(this.modelValue); + this.selectionFormControl.patchValue(this.modelValue, {emitEvent: false}); + } + + onFocus() { + this.onSelectionChange(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.selectionFormControl.disable({emitEvent: false}); + this.closeAutocomplete(); + } else { + this.selectionFormControl.enable({emitEvent: false}); + } + } + + onInputScroll(event: Event) { + const scrollLeft = (event.target as HTMLInputElement).scrollLeft; + const scrollTop = (event.target as HTMLInputElement).scrollTop; + if (this.highlightTextRef && this.highlightTextRef.nativeElement) { + this.highlightTextRef.nativeElement.scrollLeft = scrollLeft; + this.highlightTextRef.nativeElement.scrollTop = scrollTop; + } + } + + optionSelected(value: string) { + const position = this.inputRef.nativeElement.selectionStart; + const triggerIndex = this.predefinedValuesButtonMode ? position : this.modelValue.lastIndexOf(this.patternSymbol, position - 1); + if (triggerIndex === -1) { + return; + } + let prepareValue: string; + switch (this.brackets) { + case 'curly': + prepareValue = `{${value}}`; + break; + case 'square': + prepareValue = `[${value}]`; + break; + default: + prepareValue = value; + } + const base = this.modelValue ?? ''; + const newText = `${base.substring(0, triggerIndex)}${this.patternSymbol}${prepareValue}${base.substring(position)}`; + this.selectionFormControl.patchValue(newText); + this.searchText = ''; + setTimeout(() => { + this.inputRef.nativeElement.setSelectionRange(triggerIndex + prepareValue.length + 1, triggerIndex + prepareValue.length + 1); + this.inputRef.nativeElement.focus(); + }); + this.closeAutocomplete(); + } + + openValues($event: MouseEvent) { + $event.stopPropagation(); + const selectionStart = this.inputRef.nativeElement.selectionStart; + this.filteredOptions = [...this.predefinedValues]; + this.predefinedValuesButtonMode = true; + this.openAutocomplete(selectionStart); + } + + private updateView(value: string) { + if (this.modelValue !== value) { + this.modelValue = value; + this.highlightedHtml = this.getHighlightHtml(this.modelValue); + this.propagateChange(this.modelValue); + } + } + + private onSelectionChange() { + const selectionStart = this.inputRef.nativeElement.selectionStart; + const selectionEnd = this.inputRef.nativeElement.selectionEnd; + if (selectionStart === selectionEnd && selectionStart > 0 && !this.disabled) { + this.filteredOptions = this.getFilteredOptions(this.modelValue, selectionStart); + this.activeOptionIndex = 0; + } else { + this.filteredOptions = []; + this.activeOptionIndex = -1; + } + if (this.filteredOptions.length) { + this.openAutocomplete(selectionStart); + } else { + this.closeAutocomplete() + } + } + + private getHighlightHtml(text: string): string { + if (!text) { + return ''; + } + let regex: RegExp; + let simpleGroup = false; + text = text.replace(//g, '>'); + switch (this.brackets) { + case 'curly': + regex = new RegExp(`([${this.patternSymbol}](\\{([^}]*)\\}))`, 'g'); + break; + case 'square': + regex = new RegExp(`([${this.patternSymbol}](\\[([^]]*)\\]))`, 'g'); + break; + default: + regex = new RegExp(`([${this.patternSymbol}](\\w+))`, 'g'); + simpleGroup = true + break + } + return text.replace(regex, (_match: string, p1: string, p2: string, p3: string) => { + const value = simpleGroup ? p2 : p3; + if (this.predefinedValues.includes(value)) { + return `${p1}` + } + return p1; + }); + } + + private getFilteredOptions(text: string, index = text.length): Array { + const triggerIndex = text.lastIndexOf(this.patternSymbol, index - 1); + if (triggerIndex === -1) { + return []; + } + + let currentWordEndIndex = text.indexOf(' ', index); + + if (currentWordEndIndex === -1) { + currentWordEndIndex = text.length; + } + + let startOffset = 1; + switch (this.brackets) { + case 'curly': + if (text[triggerIndex + startOffset] === '{') { + startOffset++; + } + break; + case 'square': + if (text[triggerIndex + startOffset] === '[') { + startOffset++; + } + break; + } + + this.searchText = text.substring(triggerIndex + startOffset, currentWordEndIndex).toLowerCase(); + if (this.searchText.includes(' ')) { + return []; + } + + const result = this.predefinedValues.filter(value => value.toLowerCase().startsWith(this.searchText)); + if (result.length === 1 && result[0].toLowerCase() === this.searchText) { + return []; + } + return result; + } + + private openAutocomplete(cursorIndex?: number): void { + const patternIndex = this.predefinedValuesButtonMode ? cursorIndex : this.modelValue.lastIndexOf(this.patternSymbol, cursorIndex - 1); + if (!this.overlayRef) { + this.overlayRef = this.overlay.create({ + positionStrategy: this.getOverlayPosition(patternIndex), + scrollStrategy: this.overlay.scrollStrategies.reposition(), + panelClass: 'tb-select-overlay', + backdropClass: 'cdk-overlay-transparent-backdrop', + hasBackdrop: true + }); + this.overlayRef.backdropClick().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.closeAutocomplete()); + } + if (!this.overlayRef.hasAttached()) { + const templatePortal = new TemplatePortal(this.autocompleteTemplate, this.viewContainerRef); + this.overlayRef.attach(templatePortal); + this.overlayRef.updatePositionStrategy(this.getOverlayPosition(patternIndex)) + } + } + + private closeAutocomplete(): void { + this.predefinedValuesButtonMode = false; + if (this.overlayRef && this.overlayRef.hasAttached()) { + this.overlayRef.detach(); + } + } + + private getOverlayPosition(patternIndex: number): PositionStrategy { + const strategy = this.overlay + .position() + .flexibleConnectedTo(this.inputRef) + .withFlexibleDimensions(false) + .withPush(false); + + this.setStrategyPositions(strategy, patternIndex); + return strategy; + } + + private setStrategyPositions(positionStrategy: FlexibleConnectedPositionStrategy, patternIndex: number) { + let offsetX = patternIndex > 0 ? patternIndex * 8 : 0; + const inputBounds = this.inputRef.nativeElement.getBoundingClientRect(); + if (offsetX + 180 > inputBounds.width) { + offsetX = inputBounds.width - 180; + } + const belowPositions: ConnectedPosition[] = [ + {originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetX}, + {originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetX}, + ]; + + const panelClass = 'mat-mdc-autocomplete-panel-above'; + const abovePositions: ConnectedPosition[] = [ + {originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', panelClass, offsetX}, + {originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', panelClass, offsetX}, + ]; + + const positions = [...belowPositions, ...abovePositions]; + positionStrategy.withPositions(positions); + } + + private setActiveOption(index: number): void { + if (this.filteredOptions.length === 0) { + this.activeOptionIndex = -1; + return; + } + + this.activeOptionIndex = Math.max(-1, Math.min(index, this.filteredOptions.length - 1)); + this.cd.markForCheck(); + + if (this.activeOptionIndex >= 0 && this.optionItems.get(this.activeOptionIndex)) { + this.optionItems.get(this.activeOptionIndex).nativeElement.scrollIntoView({ block: 'nearest' }) + } + } + + private handleKeydown(event: KeyboardEvent): void { + if (!this.overlayRef?.hasAttached()) { + return; + } + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.setActiveOption(this.activeOptionIndex + 1); + break; + case 'ArrowUp': + event.preventDefault(); + this.setActiveOption(this.activeOptionIndex - 1); + break; + case 'Enter': + event.preventDefault(); + if (this.activeOptionIndex >= 0 && this.activeOptionIndex < this.filteredOptions.length) { + this.optionSelected(this.filteredOptions[this.activeOptionIndex]); + } + break; + case 'Escape': + this.closeAutocomplete(); + break; + } + } +} diff --git a/ui-ngx/src/app/shared/components/svg-xml.component.html b/ui-ngx/src/app/shared/components/svg-xml.component.html index 610ac34a62..55ba188547 100644 --- a/ui-ngx/src/app/shared/components/svg-xml.component.html +++ b/ui-ngx/src/app/shared/components/svg-xml.component.html @@ -1,6 +1,6 @@ -
- + + subscriptSizing="dynamic"> @if (labelText && !inlineField) { {{ labelText }} } - +
@if (inlineField) { - warning - - } @else { - - - {{ hasError }} - + matTooltipPosition="above" + matTooltipClass="tb-error-tooltip" + [matTooltip]="hasError" + *ngIf="hasError" + class="tb-error"> + warning + } + {{ hintText }} + + {{ hasError }} +
- @if (!inlineField) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts b/ui-ngx/src/app/shared/components/time-unit-input.component.ts similarity index 58% rename from ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts rename to ui-ngx/src/app/shared/components/time-unit-input.component.ts index e31d9abf9e..ce1c7bd817 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts +++ b/ui-ngx/src/app/shared/components/time-unit-input.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2025 The Thingsboard Authors +/// Copyright © 2016-2026 The Thingsboard Authors /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { Component, DestroyRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; import { AbstractControl, ControlValueAccessor, @@ -23,9 +23,10 @@ import { NG_VALUE_ACCESSOR, ValidationErrors, Validator, + ValidatorFn, Validators } from '@angular/forms'; -import { TimeUnit, timeUnitTranslations } from '../rule-node-config.models'; +import { TimeUnit, timeUnitTranslations } from '@home/components/rule-node/rule-node-config.models'; import { isDefinedAndNotNull, isNumeric } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { coerceBoolean, coerceNumber } from '@shared/decorators/coercion'; @@ -50,11 +51,14 @@ interface TimeUnitInputModel { multi: true }] }) -export class TimeUnitInputComponent implements ControlValueAccessor, Validator, OnInit { +export class TimeUnitInputComponent implements ControlValueAccessor, Validator, OnInit, OnChanges { @Input() labelText: string; + @Input() + hintText: string; + @Input() @coerceBoolean() required: boolean; @@ -76,6 +80,13 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, @Input() maxErrorText: string; + @Input() + @coerceNumber() + stepMultipleOf: number; + + @Input() + stepMultipleOfErrorText: string; + @Input() subscriptSizing: SubscriptSizing = 'fixed'; @@ -86,6 +97,13 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, @coerceBoolean() inlineField: boolean; + @Input() + @coerceBoolean() + sameWidthInputs: boolean = false; + + @Input() + containerClass: string | string[] | Record = "flex gap-4"; + timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[]; timeUnitTranslations = timeUnitTranslations; @@ -102,6 +120,8 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, [TimeUnit.SECONDS, SECOND/SECOND], ]); + minValueValidator = 0; + private modelValue: number; private propagateChange: (value: any) => void = () => {}; @@ -111,17 +131,10 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, } ngOnInit() { - if (this.maxTime) { - const maxTimeMs = this.maxTime * SECOND; - if (maxTimeMs < MINUTE) { - this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.MINUTES && item !== TimeUnit.HOURS && item !== TimeUnit.DAYS); - } else if (maxTimeMs < HOUR) { - this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.HOURS && item !== TimeUnit.DAYS); - } else if (maxTimeMs < DAY) { - this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.DAYS); - } + if (isDefinedAndNotNull(this.maxTime)) { + this.updatedAllowTimeUnitInterval(this.maxTime); } - if(this.required || this.maxTime) { + if (this.required || this.maxTime || isDefinedAndNotNull(this.minTime) || this.stepMultipleOf) { const timeControl = this.timeInputForm.get('time'); const validators = [Validators.pattern(/^\d*$/)]; if (this.required) { @@ -133,7 +146,14 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, ); } if (isDefinedAndNotNull(this.minTime)) { - validators.push(Validators.min(this.minTime)); + this.minValueValidator = this.minValue; + validators.push((control: AbstractControl) => + Validators.min(this.minValueValidator)(control) + ); + } + + if (isDefinedAndNotNull(this.stepMultipleOf) && this.stepMultipleOf > 0) { + validators.push(this.createStepMultipleOfValidator()); } timeControl.setValidators(validators); @@ -143,6 +163,9 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, this.timeInputForm.get('timeUnit').valueChanges.pipe( takeUntilDestroyed(this.destroyRef) ).subscribe(() => { + if (isDefinedAndNotNull(this.minTime)) { + this.minValueValidator = this.minValue; + } this.timeInputForm.get('time').updateValueAndValidity({onlySelf: true}); this.timeInputForm.get('time').markAsTouched({onlySelf: true}); }); @@ -154,6 +177,10 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, }); } + get minValue(): number { + return Math.ceil(this.minTime / this.timeIntervalsInSec.get(this.timeInputForm.get('timeUnit').value)); + } + get hasError(): string { if (this.timeInputForm.get('time').hasError('required') && this.requiredText) { return this.requiredText; @@ -161,6 +188,26 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, return this.minErrorText; } else if (this.timeInputForm.get('time').hasError('max') && this.maxErrorText) { return this.maxErrorText; + } else if (this.timeInputForm.get('time').hasError('stepMultipleOf') && this.stepMultipleOfErrorText) { + return this.stepMultipleOfErrorText; + } + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (propName === 'maxTime') { + if (isDefinedAndNotNull(this.maxTime)) { + this.timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[]; + this.updatedAllowTimeUnitInterval(this.maxTime); + this.timeInputForm.get('time').updateValueAndValidity({emitEvent: false}); + } + } + if (propName === 'minTime') { + this.minValueValidator = isDefinedAndNotNull(this.minTime) ? this.minValue : 0; + } + } } } @@ -176,7 +223,7 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, this.timeInputForm.disable({emitEvent: false}); } else { this.timeInputForm.enable({emitEvent: false}); - if(this.timeInputForm.invalid) { + if(!this.timeInputForm.valid) { setTimeout(() => this.updatedModel(this.timeInputForm.value, true)) } } @@ -185,7 +232,7 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, writeValue(sec: number) { if (sec !== this.modelValue) { if (isDefinedAndNotNull(sec) && isNumeric(sec) && Number(sec) !== 0) { - this.timeInputForm.patchValue(this.parseTime(sec), {emitEvent: false}); + this.timeInputForm.patchValue(this.parseTime(sec), {emitEvent: true}); this.modelValue = sec; } else { this.timeInputForm.patchValue({ @@ -198,7 +245,7 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, } validate(): ValidationErrors | null { - return this.timeInputForm.valid ? null : { + return this.timeInputForm.disabled || this.timeInputForm.valid ? null : { timeInput: false }; } @@ -223,4 +270,46 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, } } + private createStepMultipleOfValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const time = control.value; + if (!isDefinedAndNotNull(time) || !isNumeric(time)) { + return null; + } + const numericTime = Number(time); + if (numericTime === 0) { + return null; + } + + const timeUnit = control.parent?.get('timeUnit')?.value as TimeUnit; + if (!timeUnit) { + return null; + } + + const unitInSec = this.timeIntervalsInSec.get(timeUnit); + const totalTimeInSec = numericTime * unitInSec; + const multipleOfVal = this.stepMultipleOf; + + let isValid: boolean; + if (totalTimeInSec < multipleOfVal) { + isValid = (multipleOfVal % totalTimeInSec === 0); + } else { + isValid = (totalTimeInSec % multipleOfVal === 0); + } + return isValid ? null : { stepMultipleOf: true }; + }; + } + + private updatedAllowTimeUnitInterval(maxTime: number) { + const maxTimeMs = maxTime * SECOND; + this.timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[]; + if (maxTimeMs < MINUTE) { + this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.MINUTES && item !== TimeUnit.HOURS && item !== TimeUnit.DAYS); + } else if (maxTimeMs < HOUR) { + this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.HOURS && item !== TimeUnit.DAYS); + } else if (maxTimeMs < DAY) { + this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.DAYS); + } + } + } diff --git a/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.html b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.html index 055c54639b..8738fd2506 100644 --- a/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.html +++ b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.html @@ -1,6 +1,6 @@ - + {{ 'version-control.branch' | translate }} ; diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html index f0339ddbeb..8332d0a268 100644 --- a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html +++ b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html @@ -1,6 +1,6 @@